Expanded와 Flexible로 공간 분배하기

웹사이트를 만들 때 “이 박스가 남은 공간을 모두 차지했으면 좋겠는데…” 하고 생각해본 적 있나요? Flitter의 ExpandedFlexible 위젯이 바로 그 문제를 해결해줍니다.

🎯 학습 목표

이 튜토리얼을 완료하면 다음을 할 수 있습니다:

  • Expanded로 남은 공간을 균등하게 나누기
  • flex 속성으로 공간 비율 조정하기
  • Flexible과 Expanded의 차이점 이해하기
  • 실용적인 레이아웃 패턴 만들기
  • FlexFit 속성의 동작 원리 이해하기

🤔 공간 분배가 왜 중요할까요?

보통 Row나 Column에 위젯들을 넣으면, 각 위젯은 자신이 필요한 만큼의 공간만 차지합니다. 하지만 현대적인 UI에서는 화면 크기에 맞춰 유연하게 공간을 활용해야 합니다.

실제 사용 사례:

  • 네비게이션 바에서 로고는 왼쪽에, 메뉴는 오른쪽에 배치
  • 카드 레이아웃에서 여러 박스가 균등한 크기로 분배
  • 진행률 표시줄에서 완료된 부분과 남은 부분 표시
  • 모바일 앱의 탭 바에서 각 탭이 동일한 너비로 분배
  • 대시보드에서 사이드바와 메인 컨텐츠 영역 비율 조정

🏗️ Expanded 위젯 완전 정복

Expanded란?

Expanded는 Row, Column, Flex 위젯의 자식 위젯이 사용 가능한 공간을 모두 채우도록 강제하는 위젯입니다.

기본 속성

Expanded({
  flex: 1,              // 공간 비율 (기본값: 1)
  child: Widget         // 자식 위젯 (필수)
})

1. 기본적인 Expanded 사용

Row({
  children: [
    Container({
      width: 100,
      height: 50,
      color: '#FF6B6B',
      child: Text("고정 크기")
    }),
    Expanded({
      child: Container({
        height: 50,
        color: '#4ECDC4',
        child: Text("남은 공간 모두 차지")
      })
    })
  ]
})

결과: 첫 번째 박스는 100px 너비를 가지고, 두 번째 박스는 남은 공간을 모두 차지합니다.

2. 여러 개의 Expanded로 균등 분배

Row({
  children: [
    Expanded({
      child: Container({
        height: 100,
        color: '#FF6B6B',
        child: Text("1/3")
      })
    }),
    Expanded({
      child: Container({
        height: 100,
        color: '#4ECDC4',
        child: Text("1/3")
      })
    }),
    Expanded({
      child: Container({
        height: 100,
        color: '#45B7D1',
        child: Text("1/3")
      })
    })
  ]
})

결과: 3개의 박스가 화면 너비를 균등하게 1/3씩 나누어 차지합니다.

🎨 실습: 균등하게 나누어진 3개 박스 만들기

위의 시작 코드에서 3개의 박스가 고정 크기(width: 100)로 되어있습니다. 이를 Expanded를 사용해서 균등하게 공간을 나누도록 수정해보세요.

단계별 힌트:

  1. Expanded 위젯을 import에 추가
  2. 각 Container를 Expanded로 감싸기
  3. Container에서 width 속성 제거하기 (Expanded가 자동으로 처리)
  4. height는 그대로 유지

⚖️ flex 속성으로 비율 조정하기

flex 속성을 사용하면 각 Expanded 위젯이 차지할 공간의 비율을 정할 수 있습니다.

flex 비율 계산 원리

Row({
  children: [
    Expanded({
      flex: 1,  // 1 / (1 + 2 + 3) = 1/6
      child: Container({
        height: 100,
        color: '#FF6B6B',
        child: Text("1")
      })
    }),
    Expanded({
      flex: 2,  // 2 / (1 + 2 + 3) = 2/6 = 1/3
      child: Container({
        height: 100,
        color: '#4ECDC4',
        child: Text("2")
      })
    }),
    Expanded({
      flex: 3,  // 3 / (1 + 2 + 3) = 3/6 = 1/2
      child: Container({
        height: 100,
        color: '#45B7D1',
        child: Text("3")
      })
    })
  ]
})

계산 과정:

  • 전체 flex 합계: 1 + 2 + 3 = 6
  • 첫 번째 박스: 1/6 (약 16.7%)
  • 두 번째 박스: 2/6 = 1/3 (약 33.3%)
  • 세 번째 박스: 3/6 = 1/2 (50%)

결과: 세 번째 박스가 가장 크고, 첫 번째 박스가 가장 작습니다.

실용적인 비율 예제

// 사이드바(1/4)와 메인 컨텐츠(3/4) 레이아웃
Row({
  children: [
    Expanded({
      flex: 1,  // 25%
      child: Container({
        color: '#F7FAFC',
        child: Text("사이드바")
      })
    }),
    Expanded({
      flex: 3,  // 75%
      child: Container({
        color: '#EDF2F7',
        child: Text("메인 컨텐츠")
      })
    })
  ]
})

🔄 Flexible 위젯 심화 이해

Flexible이란?

FlexibleExpanded보다 유연한 공간 분배를 제공합니다. 자식 위젯이 실제로 필요한 공간만큼만 차지할 수 있도록 합니다.

Flexible 속성

Flexible({
  flex: 1,                    // 공간 비율 (기본값: 1)
  fit: FlexFit.loose,         // 공간 사용 방식 (기본값: loose)
  child: Widget               // 자식 위젯 (필수)
})

FlexFit 옵션

  • FlexFit.loose: 필요한 만큼만 공간 사용 (기본값)
  • FlexFit.tight: 할당된 공간을 모두 사용 (Expanded와 동일)

Expanded vs Flexible 비교

특성ExpandedFlexible
기본 동작할당된 공간을 모두 차지필요한 만큼만 차지
FlexFit항상 tightloose (기본값) 또는 tight
사용 목적공간을 완전히 채우고 싶을 때유연한 크기 조정이 필요할 때
관계Flexible의 특수한 경우더 일반적인 위젯

Flexible 사용 예제

Row({
  children: [
    Flexible({
      child: Container({
        height: 50,
        color: '#FF6B6B',
        child: Text("짧은 텍스트")  // 텍스트 크기만큼만 공간 차지
      })
    }),
    Flexible({
      child: Container({
        height: 50,
        color: '#4ECDC4',
        child: Text("이것은 매우 긴 텍스트입니다")  // 텍스트 크기만큼 공간 차지
      })
    }),
    Container({
      width: 100,
      height: 50,
      color: '#45B7D1',
      child: Text("고정 크기")
    })
  ]
})

결과: 텍스트가 짧은 첫 번째 박스는 작은 공간을 차지하고, 긴 텍스트를 가진 두 번째 박스는 더 많은 공간을 차지합니다.

🏆 실전 예제 모음

1. 모던 네비게이션 바

import { StatelessWidget, Container, Row, Text, Expanded, EdgeInsets, TextStyle } from "@meursyphus/flitter";

class ModernNavigationBar extends StatelessWidget {
  build(context) {
    return Container({
      height: 64,
      color: '#1A202C',
      padding: EdgeInsets.symmetric({ horizontal: 24, vertical: 12 }),
      child: Row({
        children: [
          // 로고 영역
          Container({
            padding: EdgeInsets.only({ right: 16 }),
            child: Text("MyApp", {
              style: new TextStyle({
                color: 'white',
                fontSize: 20,
                fontWeight: 'bold'
              })
            })
          }),
          
          // 중간 빈 공간
          Expanded({
            child: Container()
          }),
          
          // 메뉴 영역
          Row({
            children: [
              Text("", { style: { color: 'white', marginRight: 24 } }),
              Text("제품", { style: { color: 'white', marginRight: 24 } }),
              Text("회사소개", { style: { color: 'white', marginRight: 24 } }),
              Text("연락처", { style: { color: 'white' } })
            ]
          })
        ]
      })
    });
  }
}

export default function ModernNavigationBar(props) {
  return new _ModernNavigationBar(props);
}

2. 인터랙티브 진행률 표시기

import { StatefulWidget, State, Container, Column, Row, Text, Expanded, GestureDetector, EdgeInsets, BoxDecoration, BorderRadius, TextStyle, Center } from "@meursyphus/flitter";

class InteractiveProgressBar extends StatefulWidget {
  constructor({ initialProgress = 0.3, ...props } = {}) {
    super(props);
    this.initialProgress = initialProgress;
  }

  createState() {
    return new _InteractiveProgressBarState();
  }
}

class _InteractiveProgressBarState extends State {
  progress = 0.3;

  initState() {
    super.initState();
    this.progress = this.widget.initialProgress;
  }

  increaseProgress() {
    this.setState(() => {
      this.progress = Math.min(1.0, this.progress + 0.1);
    });
  }

  decreaseProgress() {
    this.setState(() => {
      this.progress = Math.max(0.0, this.progress - 0.1);
    });
  }

  build(context) {
    return Column({
      children: [
        // 진행률 바
        Container({
          height: 24,
          margin: EdgeInsets.only({ bottom: 16 }),
          decoration: new BoxDecoration({
            color: '#E2E8F0',
            borderRadius: BorderRadius.circular(12)
          }),
          child: Row({
            children: [
              Expanded({
                flex: Math.round(this.progress * 100),
                child: Container({
                  decoration: new BoxDecoration({
                    color: '#4299E1',
                    borderRadius: BorderRadius.circular(12)
                  })
                })
              }),
              Expanded({
                flex: Math.round((1 - this.progress) * 100),
                child: Container()
              })
            ]
          })
        }),
        
        // 진행률 텍스트
        Text(`진행률: ${Math.round(this.progress * 100)}%`, {
          style: new TextStyle({ fontSize: 16, fontWeight: 'bold' })
        }),
        
        // 컨트롤 버튼
        Row({
          children: [
            Expanded({
              child: GestureDetector({
                onClick: () => this.decreaseProgress(),
                child: Container({
                  height: 40,
                  margin: EdgeInsets.only({ right: 8 }),
                  decoration: new BoxDecoration({
                    color: '#E53E3E',
                    borderRadius: BorderRadius.circular(4)
                  }),
                  child: Center({ child: Text("감소", { style: new TextStyle({ color: 'white' }) }) })
                })
              })
            }),
            Expanded({
              child: GestureDetector({
                onClick: () => this.increaseProgress(),
                child: Container({
                  height: 40,
                  margin: EdgeInsets.only({ left: 8 }),
                  decoration: new BoxDecoration({
                    color: '#38A169',
                    borderRadius: BorderRadius.circular(4)
                  }),
                  child: Center({ child: Text("증가", { style: new TextStyle({ color: 'white' }) }) })
                })
              })
            })
          ]
        })
      ]
    });
  }
}

export default function InteractiveProgressBar(props) {
  return new _InteractiveProgressBar(props);
}

3. 반응형 카드 그리드

import { StatelessWidget, Container, Row, Text, Column, Expanded, EdgeInsets, BoxDecoration, BorderRadius, BoxShadow, TextStyle, CrossAxisAlignment } from "@meursyphus/flitter";

class ResponsiveCardGrid extends StatelessWidget {
  constructor({ cards = [], ...props } = {}) {
    super(props);
    this.cards = cards;
  }

  build(context) {
    return Row({
      children: this.cards.map((card, index) => (
        Expanded({
          child: Container({
            height: 120,
            margin: EdgeInsets.only({ 
              left: index > 0 ? 8 : 0,
              right: index < this.cards.length - 1 ? 8 : 0
            }),
            decoration: new BoxDecoration({
              color: card.color,
              borderRadius: BorderRadius.circular(8),
              boxShadow: [new BoxShadow({
                color: 'rgba(0, 0, 0, 0.1)',
                offset: { dx: 0, dy: 2 },
                blurRadius: 4
              })]
            }),
            padding: EdgeInsets.all(16),
            child: Column({
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(card.title, {
                  style: new TextStyle({
                    fontSize: 16,
                    fontWeight: 'bold',
                    color: 'white',
                    marginBottom: 8
                  }
                }),
                Text(card.description, {
                  style: new TextStyle({
                    fontSize: 14,
                    color: 'rgba(255, 255, 255, 0.8)'
                  })
                })
              ]
            })
          })
        })
      ))
    });
  }
}

export default function ResponsiveCardGrid(props) {
  return new _ResponsiveCardGrid(props);
}

// 사용 예제
const cardData = [
  {
    title: "매출",
    description: "이번 달 매출 현황",
    color: '#4299E1'
  },
  {
    title: "주문",
    description: "신규 주문 처리",
    color: '#48BB78'
  },
  {
    title: "고객",
    description: "고객 만족도 조사",
    color: '#ED8936'
  }
];

const widget = Container({
  padding: EdgeInsets.all(16),
  child: ResponsiveCardGrid({ cards: cardData })
});

🚀 고급 활용 패턴

1. 중첩된 Expanded 사용

Column({
  children: [
    Container({
      height: 60,
      color: '#2D3748',
      child: Text("헤더")
    }),
    Expanded({
      child: Row({
        children: [
          Container({
            width: 200,
            color: '#4A5568',
            child: Text("사이드바")
          }),
          Expanded({
            child: Container({
              color: '#F7FAFC',
              child: Text("메인 컨텐츠")
            })
          })
        ]
      })
    }),
    Container({
      height: 40,
      color: '#718096',
      child: Text("푸터")
    })
  ]
})

2. Spacer 위젯 활용

Row({
  children: [
    Text("왼쪽 컨텐츠"),
    Spacer(),  // Expanded({ child: Container() })와 동일
    Text("오른쪽 컨텐츠")
  ]
})

// 여러 개의 Spacer로 균등 분배
Row({
  children: [
    Text("A"),
    Spacer(),
    Text("B"),
    Spacer(),
    Text("C")
  ]
})

📝 연습 문제

연습 1: 탭 바 만들기

4개의 탭이 균등하게 배치된 탭 바를 만들어보세요.

// TODO: 4개의 탭을 균등하게 배치하는 탭 바를 만들어보세요
const tabNames = ["", "검색", "알림", "프로필"];

// 힌트: Row와 Expanded를 사용하세요
const tabBar = Row({
  children: [
    // 여기에 코드를 작성하세요
  ]
});

연습 2: 대시보드 레이아웃

왼쪽 사이드바(1/4), 메인 컨텐츠(1/2), 오른쪽 패널(1/4)로 구성된 3단 레이아웃을 만들어보세요.

// TODO: 1:2:1 비율의 3단 레이아웃을 만들어보세요
const dashboardLayout = Row({
  children: [
    // 여기에 코드를 작성하세요
  ]
});

연습 3: 동적 진행률 바

버튼을 클릭할 때마다 진행률이 20%씩 증가하는 진행률 바를 만들어보세요.

// TODO: StatefulWidget을 사용해서 동적 진행률 바를 만들어보세요
class DynamicProgressBar extends StatefulWidget {
  createState() {
    return new _DynamicProgressBarState();
  }
}

class _DynamicProgressBarState extends State {
  // 여기에 코드를 작성하세요
}

🐛 흔한 실수와 해결법

❌ 실수 1: Row/Column 밖에서 Expanded 사용

// 에러 발생!
Container({
  child: Expanded({
    child: Text("Hello")
  })
})

✅ 올바른 방법:

// Expanded는 반드시 Flex 위젯(Row, Column, Flex) 안에서 사용
Row({
  children: [
    Expanded({
      child: Text("Hello")
    })
  ]
})

❌ 실수 2: Expanded 안에 무한 크기 설정

// 에러 발생!
Row({
  children: [
    Expanded({
      child: Container({ width: double.infinity })
    })
  ]
})

✅ 올바른 방법:

// Expanded가 이미 최대 크기를 제공하므로 width 지정 불필요
Row({
  children: [
    Expanded({
      child: Container()  // width는 자동으로 결정됨
    })
  ]
})

❌ 실수 3: Column에서 height 무한대 시도

// 에러 발생!
Column({
  children: [
    Container({ height: double.infinity })
  ]
})

✅ 올바른 방법:

// Column에서 세로 공간을 모두 차지하려면 Expanded 사용
Column({
  children: [
    Expanded({
      child: Container()
    })
  ]
})

❌ 실수 4: flex 값을 0으로 설정

// 잘못된 사용
Expanded({
  flex: 0,  // 0은 의미가 없음
  child: Container()
})

✅ 올바른 방법:

// flex는 1 이상의 양수 사용
Expanded({
  flex: 1,  // 기본값 사용 또는 적절한 비율 설정
  child: Container()
})

🎊 완성된 결과

이 튜토리얼을 완료하면:

  1. 기본 구현: 3개의 박스가 화면 너비를 균등하게 나누어 차지
  2. 반응형 동작: 창 크기를 바꿔도 비율이 유지됨
  3. 시각적 구분: 각 박스는 서로 다른 색상으로 구분됨
  4. 비율 제어: flex 속성으로 원하는 비율로 조정 가능

🔥 추가 도전 과제

1. 반응형 그리드 시스템 만들기

화면 크기에 따라 열 개수가 달라지는 그리드 시스템을 만들어보세요.

2. 애니메이션 진행률 바

시간에 따라 자동으로 진행률이 증가하는 애니메이션 진행률 바를 만들어보세요.

3. 복잡한 대시보드 레이아웃

헤더, 사이드바, 메인 컨텐츠, 우측 패널, 푸터를 포함한 완전한 대시보드를 만들어보세요.

4. 터치 인터랙션 추가

드래그로 각 영역의 크기를 조정할 수 있는 인터랙티브 레이아웃을 만들어보세요.

🎯 다음 단계

다음 튜토리얼에서는 **Padding과 여백 관리**를 배워보겠습니다. 시각적으로 깔끔하고 전문적인 레이아웃을 만드는 핵심 기술을 익혀보세요!