[플러터 인 액션] C3 : 플러터의 세계로 (2) 레이아웃

플러터 레이아웃

  • 플러터 렌더링 엔진은 한 가지 정해진 레이아웃 시스템을 사용하지 않는다는 특징이 있다.
  • 위젯은 뷰를 묘사하는 고수준 클래스이며, 위젯을 화면에 그리는 부분은 저수준 객체가 담당한다.

Row와 Column

  • Column, Row 위젯으로 플렉시블 레이아웃을 활용할 수 있다.
  • Column은 자식들을 열로 배치하고, Row는 행으로 배치한다.

레이아웃 제약 조건

  • 플러터는 UI 라이브러리이며, 렌더링 엔진이기 때문에 레이아웃 제약 조건은 중요한 개념이다.
  • 플러터 개발 중에 flutter layout in-finite size(플러터 레이아웃 무한 크기)에 대한 오류를 겪게 된다.

제약 조건이 어떻게 동작하는지 이해하여, 오류에 대응해야 한다.

RenderObject

  • 이 클래스는 내부에서 사용되는 클래스이므로 플러터 앱 개발자가 이를 직접 적용할 일은 거의 없다.
  • 모든 렌더 객체가 모여 렌더 트리를 구성한다.
  • 렌더 트리는 RenderObject를 구현하는 클래스로 구성되며 렌더 객체는 각자 대응하는 위젯을 가진다.
  • 개발자는 렌더 객체에 제약 조건에 대한 데이터를 제공한다.
    • 렌더 객체는 performLayout, paint 같은 메서드를 제공
    • 이 메서드는 화면에 픽셀을 그리는 역할 수행
  • 렌더 객체는 상태나 로직을 포함하지 않는다.

위젯은 build 메서드에서 자식 메서드를 만들 수 있으므로 트리의 가장 아래에 내려가야 Ren-derObjectWidget을 찾을 수 있다. 이는 화면을 그리는 렌더 객체를 만드는 위젯이며, 실제로 적용할 일은 없고 제약 조건을 제공하여 제어한다.

RenderObject와 제약 조건

  • 렌더 객체는 레이아웃 제약 조건과 긴밀히 연결되어 있다.
  • 위젯의 제약 조건을 설정하면 렌더 객체가 최종적으로 프레임워크에 위젯의 실제 물리적 크기를 전달한다.
  • 제약 조건은 minWidth, minHeight, axWidth, maxHeight 등의 프로퍼티로 설정한다.
  • RenderBox를 활용하면 렌더 객체 서브클래스로 데카르트 좌표계를 기반으로 위젯 크기를 계산한다.
    • Center 위젯 : 최대 공간 차지
    • Opacity 위젯 : 자식과 같은 크기의 공간 차지
    • Image 위젯 : 특정 크기의 공간 차지

RenderBox와 레이아웃 오류

  • flutter layoutinfinitesize 오류는 수평, 수직으로 무한 크기를 갖도록 제약 조건이 설정되면 발생한다.
  • maxHeight나 maxWidth를 제공하면 렌더 상자는 double.INFINITY 값을 가진다.
  • Row, Column은 데카르트 좌표계 기반의 세 가지 렌더 객체 동작 유형에 속하지 않으며, 부모가 전달한 제약 조건에 따라 다른 동작을 수행한다.
  • 부모가 한정된 제약 조건을 가지면 이들은 한정된 제약 조건 내에서 최대 공간을 차지한다.
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          // Column에서 한정되지 않은 높이를 자식 제약 조건으로 제공할 경우 
          // mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                // Expanded 위젯은 공간을 차지하도록 확장한다.
                Expanded(child: Text(""))
              ],
            )
          ],
        ),
      ),
    );
  }
  • 만약 주석으로 mainAxisAlignment를 제거할 경우, 자식 Column은 부모로부터 한정되지 않은 제약 조건을 받는다.
  • 이는 오류를 발생시킨다.
  • Column과 Row는 항상 부모만큼의 너비나 높이를 가지려 노력한다.

위젯은 제약 조건을 트리 아래로 전달하므로, 중복된 플랙스 상자를 어느 정도 분리해야 하며 그렇지 않으면 어딘가에서 자식이 무한대로 확장하게 된다. Column안에 Row나 Column을 재사용하는 등의 코드를 구성할 때 이러한 오류는 자주 발생한다.

여러 자식을 갖는 위젯

  • 기존의 Counter에 하나의 버튼을 더 추가할 경우, 정렬 로직을 적용할 수 있다.
class MultiChildWidget extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _MultiChildWidget();
}

class _MultiChildWidget extends State<MultiChildWidget> {
  int _counter = 10;

  @override
  Widget build(BuildContext context) {
    return Row(
      //정렬 옵션 활용 
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      children: <Widget>[
        TextButton(
          style: TextButton.styleFrom(
            foregroundColor: Colors.red
          ),
          child: Text("decrement"),
          onPressed: _decrementCounter,
        ),
        TextButton(
          style: TextButton.styleFrom(
              foregroundColor: Colors.blue
          ),
          child: Text("increment"),
          onPressed: _incrementCounter,
        ),
      ],
    );
  }

  void _decrementCounter(){
    setState(() => _counter--);
  }

  void _incrementCounter(){
    setState(() => _counter++);
  }
}

아이콘과 FloatingActionButton

  • 플러터는 다양한 머티리얼 디자인 형식 아이콘을 기본으로 제공한다.
  • 이를 활용하면 외부 라이브러리를 사용하거나 이미지를 업로드할 필요가 없다.
  • 기존의 Scaffold 에 버튼을 추가한다.
      floatingActionButton: FloatingActionButton(
        onPressed: _resetCounter,
        tooltip: "Reset Counter",
        child: Icon(Icons.refresh),
      ),
  • 다른 버튼과 마찬가지로 onPressed를 가지고 있으므로, 콜백을 설정한다.
  void _resetCounter() {
    setState(()=>_counter = 10);
  }

Image

  • 플러터에서는 Image 위젯으로 앱에 이미지를 추가할 수 있다.
  • Image 위젯은 이미지 소스(로컬, 인터넷) 등에 따라 다양한 생성자를 제공한다.
  • Image.network(URL)로 모든 URL을 사용할 수 있다.
  • 로컬 이미지를 사용할 경우 Image.asset를 활용한다.
    • 이를 위해서는 pubspec.yaml 파일에 이미지 위치를 선언해야 한다.
Image.asset(cpuInput.path);

Container 위젯

class _FirstNameTextState extends State<FirstNameText> {
  late String name;

  @override
  void initState() {
    super.initState();
    name = widget.name.toUpperCase();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.only(bottom: 100.0),
      padding: EdgeInsets.all(8.0),
      decoration: BoxDecoration(
        color: Colors.blue.withOpacity(0.25),
        borderRadius: BorderRadius.circular(4.0)
      ),
      child: Text("text"),
    );
  }
}

  • Container 위젯은 개별 위젯으로 추가해야 하는 프로퍼티를 제공하며, 위젯의 스타일을 바꿀 때 유용하게 활용할 수 있다.
  • Padding 위젯으로 자식에 패딩을 추가하는 방법도 있지만, Container 위젯의 padding 프로퍼티를 이용할 수 있다.

요소 트리

  • 프레임워크의 계층

  • 앱 개발자는 대부분의 시간을 가장 윗부분인 위젯 레이어, 머티리얼/쿠퍼티노 위젯 계층에서 보내게 된다.
  • 그 아래로는 렌더링 계층과 dart:ui 라이브러리가 존재한다.
  • 다트 UI는 canvas API로 화면에 직접 그리는 기능을 제공하며 hit testing을 이용해 사용자의 상호작용을 인지한다.
    • 이는 매 프레임마다 화면의 모든 픽셀 좌표를 계산하여 갱신하므로, 플러터에서는 고수준으로 추상화된 위젯을 선언형 UI를 만들 수 있도록 제공한다.
    • 따라서 앱 개발자는 저수준 디바이스 힛 테스팅이나 픽셀 수준의 계산을 걱정할 필요가 없다.

요소 트리가 중요한 이유

  • Element 객체(요소 트리)는 프레임워크 동작 원리를 알 때 중요하다.
  • 내부 동작을 이해하면 디버깅 시 쉽게 문제를 해결할 수 있다.

렌더 객체와 요소

  • 요소는 플러터의 Element 클래스를 가리킨다.
  • 요소를 설정하면 위젯이 된다.
  • 각 Elment는 RenderObject도 포함하는데, 렌더 객체는 고수준 코드와 저수준 dart:ui 라이브러리를 연결하는 인터페이스이다.
  • 요소는 위젯과 렌더 트리를 연결하는 접착제로 생각할 수 있다.
  • 플러터 개발자가 렌더 객체를 직접 사용할 수 있지만, 이는 실제 화면에 그리는 작업을 수행하므로 복잡하고 비싸다.

  • 요소는 자신을 설정하는 위젯을 참조하며, dart:ui로 화면에 요소를 그리는 렌더 객체를 가진다.

요소와 위젯

  • 위젯이 요소를 만든다.
  • 새 위젯을 만들면 프레임워크가 Widget.createElement(this)를 호출한다.
  • 이때 요소를 설정하며 요소는 자신을 만든 위젯을 참조하기 시작한다.
  • 요소 트리는 앱의 골결과 같다.
  • 요소는 다시 빌드되지 않는다는 점(오직 갱신된다)에서 위젯과 다르다.
  • 위젯은 다시 빌드하거나 트리의 부모가 다른 위젯을 삽입하면 요소의 위젯 참조를 다시 만들지 않고 갱신한다.
  • 위젯은 매 프레임마다 다시 빌드되지만, 요소는 위젯 참조만 수정할 뿐이다.

플러터의 성능과 요소

  • 플러터는 위젯을 끊임없이 다시 빌드하는데도 좋은 성능을 유지할 수 있다.
  • 화면에 위젯은 실제로 표시하는 것은 요소이므로 트리의 위젯을 손쉽게 교체할 수 있다.

요소와 상태 관리

  • 플러터는 위젯이 아니라 요소와 상태 객체를 이용해 렌더링을 진행한다.
  • 위젯은 바꿀 수 없으므로 다른 위젯과의 관계도 바꿀 수 없으며 새 부모도 가질 수 없다.

위젯을 바꾸기 위해서는 파괴하고 새로 만들어야 한다. 요소는 바꿀 수 있지만 직접 바꿀 필요가 없다. 요소를 바꿀 수 있는 특징을 이용해 속도를 얻을 수 있으며, 위젯의 바꿀 수 없는 특징을 이용해 안전한 코드를 구축 가능하다.

요소 트리 살펴보기

  • 요소 트리 구조를 확인하기 위해 하나의 위젯을 생성했다.
  • Map 객체의 putIfAbsent 메서드를 사용해 모든 FancyButton의 색을 관리하는 로직을 작성했다.
// 자신의 배경색을 관리하며, 버튼을 누르면 전달된 콜백을 호출한다.
class FancyButton extends StatefulWidget {
  final VoidCallback onPressed;
  final Widget child;

  const FancyButton({
    required Key key,
    required this.onPressed,
    required this.child
  }) : super(key:key);

  @override
  _FancyButtonState createState() => _FancyButtonState();
}

class _FancyButtonState extends State<FancyButton> {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: TextButton(
        style: TextButton.styleFrom(
          foregroundColor: _getColors()
        ),
        onPressed: widget.onPressed,
        child: widget.child,
      ),
    );
  }

  Color _getColors(){
    return _buttonColors.putIfAbsent(this, ()=> colors[next(0,5)]);
  }

  final Map<_FancyButtonState,Color> _buttonColors = {};
  final _random = Random();
  int next(int min, int max) => min+_random.nextInt(max-min);
  List<Color> colors = [
    Colors.blue,
    Colors.green,
    Colors.orange,
    Colors.purple,
    Colors.amber,
    Colors.lightBlue,
  ];
}
  • 이제 커스텀한 버튼을 가지고, 새로운 위젯을 구성할 수 있다.
class MyPageWithFancyButton extends StatefulWidget {
  @override
  createState() => _MyPageWithFancyButtonState();

}

class _MyPageWithFancyButtonState extends State<MyPageWithFancyButton> {
  int _count = 10;
  bool _reversed = false;
  @override
  Widget build(BuildContext context) {
    final incrementButton = FancyButton(
        onPressed: _incrementCounter,
        child: Text("Increment")
    );
    final decrementButton = FancyButton(
        onPressed: _decrementCounter,
        child: Text("Decrement")
    );
    List<Widget> _buttons = <Widget>[incrementButton,decrementButton];
    if (_reversed){
      _buttons = _buttons.reversed.toList();
    }
    return Scaffold(
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: _buttons,
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _resetCounter,
      ),
    );
  }
  void _decrementCounter() {
    setState(() => _count--);
  }
  void _incrementCounter() {
    setState(() => _count++);
  }
  void _resetCounter(){
    setState(() => _count = 10);
    _swap();
  }
  void _swap() {
    setState(()=> _reversed = !_reversed);
  }
}
  • 기대하는 동작은 _resetCounter가 호출되었을 때, 버튼의 위치가 변경되고 색상을 변경하는 동작을 원한다.
  • 여기서 문제점은 _resetCounter가 호출되어도 버튼은 바뀌지만 색상이 변경되지 않는다.
    • 이는 요소, 상태 객체, 위젯이 함께 동작한 결과이다.

요소 트리와 상태 객체

  • 요소, 상태 객체, 위젯은 아래 사항을 기억해야 한다.
    • 상태 객체는 요소 트리가 관리한다.
    • 상태 객체는 오래 살며, 위젯과 달리 파괴되거나 빌드되지 않는다.
    • 상태 객체는 다시 사용할 수 있다.
    • 요소는 위젯을 참조한다.
  • 요소는 연산의 두뇌 역햘로, 메타 정보와 위젯 참조를 포함한다.
  • 하지만 위젯이 바뀌었을 때 래퍼런스를 어떻게 갱신해야 하는지 모른다.
  • 플러터가 위젯을 다시 빌드해도 요소는 기존 참조가 가리키는 위젯의 위치를 그대로 유지한다.
    • 이는 새 위젯으로 바뀌었음에도 동일하다.

요소와 프로퍼티

  • 요소는 바뀐 내용을 판독하기 위해서 프로퍼티를 참조한다.
    • 런타임의 정확한 형식
    • 키가 존재한다면 위젯의 키
  • 플러터는 프레임워크가 위젯을 식별할 수 있도록 키라는 기능을 제공한다.

위젯과 키

  • 상태와 요소 문제는 키로 쉽게 해결할 수 있다.
  • 여러 위젯일 있을 때 키를 이용하면, 두 위젯은 형식은 같지만 다른 위젯임을 플러터에게 명시할 수 있다.
    final incrementButton = FancyButton(
        key: _buttonKeys.first,
        onPressed: _incrementCounter,
        child: Text("Increment"));
    final decrementButton = FancyButton(
        key: _buttonKeys.last,
        onPressed: _decrementCounter,
        child: Text("Decrement"));
    List<Widget> _buttons = <Widget>[incrementButton, decrementButton];
    if (_reversed) {
      _buttons = _buttons.reversed.toList();
    }
  • 다음 위젯은 키를 중심으로 선언되었다.
  • 이는 키 값을 판별하여 요소가 바뀐 내용을 판독하도록 도우며, 이전 코드의 문제점을 해결할 수 있다.

키의 형식과 사용 방법

  • 키는 ValueKey, ObjectKey, UniqueKey, GlobalKey, PageStorageKey 등이 존재한다.
  • 일반적으로 글로벌 키와 로컬 키 두 그룹으로 나눌 수 있다.
    • 글로벌 키는 사용을 권장하지 않으며 잘 사용하지 않는다.
  • [글로벌 키]
    • 위젯 트리에서 상태를 관리하고 위젯을 이동할 때 사용한다.
    • 체크박스 등을 표시할 때 , 다양한 페이지에서 동일한 키로 사용할 수 있다.
    • 이는 같은 checked 상태를 공유한다.
    • 글로벌 키는 유용하지만 성능에 큰 영향을 미친다.
  • [로컬 키]
    • 로컬 키는 키를 새성한 빌드 콘텍스트의 영역을 가진다.
    • ValueKey<T> : 상수를 갖는 객체에 키를 추가할 때 ValueKey를 사용한다.
      • Todo.text 등 할 일 목록을 고유한 상수로 표현할 수 있다.
    • ObjectKey : 같은 형식의 객체지만 프로퍼티 값이 다른 여러 객체가 있을 때 사용한다.
      • 한 판매자가 여러 제품을 판다면 제품명과 판매자명을 조합해 특정 제품을 식별할 수 있다.
      • 이는 값 객체와 비슷한 동작을 한다.

정리

  • 플러터의 모든 것은 위젯이며, 위젯은 뷰를 묘사하는 다트 클래스이다.
  • 플러터는 상속보다 조합을 중시하며, has a 대신 is a 관계를 정의한다.
  • 모든 위젯은 위젯을 반환하는 build 메서드를 포함한다.
  • 위젯은 변경할 수 없지만 상태 객체는 바꿀 수 있다.
  • 위젯은 대부분 const 생성자를 가진다.
  • 플러터에서 위젯을 만들 때 new와 const 키워드는 생략하는 것이 좋다.
  • StatefulWidget은 상태 객체로 자신의 내부 상태를 관리한다.
    • 플러터가 위젯 트리에서 위젯을 제거하면 완전히 파괴된다.
  • setState는 플러터에 상태를 갱신하고 위젯을 다시 그리도록 지시한다.
    • 이는 비동기 작업을 수행할 수 없다.
  • initState와 기타 생명주기 메서드는 상태 객체의 강력한 도구로 활용된다.
  • BuildContext는 위젯 트리의 위치를 가리킨다.
    • 위젯은 BuildContext를 통해 트리에서 자신의 위치 정보를 얻을 수 있다.
  • 요소 트리는 영리하게 동작하며, 위젯을 관리하며 실제 사용될 요소의 청사진 역할을 한다.
  • 위젯과 관련된 RenderBox 객체가 위젯을 그린다.
    • 이들 객체는 부모로부터 제약 조건을 받아 자신의 실제 크기를 결정한다.