Custom RenderObject Widget 만들기

@beygee· October 27, 2024 · 18 min read

위젯 해부: Flutter의 SizedBox 탐구하기

Flutter 위젯, 특히 SizedBox의 내부 작동 방식을 탐구하면 Flutter 설계의 아키텍처와 계층적 접근 방식에 대해 흥미로운 통찰을 얻을 수 있습니다. 주로 UI를 구성적으로 빌드하는 StatelessWidget이나 StatefulWidget과 달리, SizedBox와 같은 위젯은 레이아웃과 페인팅을 직접 제어하여 렌더링 로직에 대해 고유한 제어를 제공합니다.

SingleChildRenderObjectWidget의 역할

SizedBox의 강력함을 이해하기 위해 정의를 살펴봅시다:

// Flutter 소스 코드
// packages/flutter/lib/src/widgets/basic.dart
class SizedBox extends SingleChildRenderObjectWidget {}

SizedBoxSingleChildRenderObjectWidget의 한 유형으로, StatelessWidget과 같은 일반적인 위젯보다 렌더링 파이프라인에서 더 근본적인 역할을 수행합니다. 이 클래스는 RenderObjectWidget을 확장하며, 주된 목적은 렌더 객체의 생성과 업데이트를 관리하는 것입니다. 이러한 렌더 객체는 Flutter의 렌더링 프로세스의 중추를 형성합니다.

위젯 상속 구조

주요 상속 체인을 분해해 보겠습니다:

abstract class SingleChildRenderObjectWidget extends RenderObjectWidget {}
abstract class RenderObjectWidget extends Widget {}

SingleChildRenderObjectWidgetRenderObjectWidget을 상속받아 단일 자식을 가지는 위젯의 렌더링 세부 사항을 직접 관리하는 역할을 강조합니다.

SizedBox의 내부 동작 방식

SizedBox의 소스 코드를 살펴보면 그 구조와 목적을 이해할 수 있습니다:

class SizedBox extends SingleChildRenderObjectWidget {
  const SizedBox({
    super.key,
    this.width,
    this.height,
    super.child,
  });

  final double? width;
  final double? height;

  
  RenderConstrainedBox createRenderObject(BuildContext context) {}

  BoxConstraints get _additionalConstraints {}

  
  void updateRenderObject(BuildContext context, RenderConstrainedBox renderObject) {}

  
  String toStringShort() {}

  
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {}
}

여기서 주요 함수는 createRenderObject()updateRenderObject()로, RenderConstrainedBox 인스턴스를 생성하고 업데이트합니다.

RenderConstrainedBox와 렌더 객체

RenderConstrainedBoxRenderProxyBox의 서브클래스로, 궁극적으로 RenderBox를 상속받습니다:

class RenderConstrainedBox extends RenderProxyBox {}
class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin<RenderBox> {}
RenderBox란 무엇인가?

RenderBox는 2D 공간에서 사용되는 특정 유형의 RenderObject로, 다음과 같은 역할을 합니다:

  1. 레이아웃 수행: UI의 각 부분의 크기와 위치를 결정합니다.
  2. 페인팅: 시각적 요소를 화면에 렌더링합니다.
  3. 수명 주기 관리: 초기화와 폐기를 포함하여 렌더 트리에서의 역할을 관리합니다.

렌더 객체의 유형

Flutter에는 세 가지 주요 렌더 객체 유형이 있습니다:

  1. RenderBox: 2D 공간에 배치된 위젯을 처리합니다.
  2. RenderSliver: 스크롤 가능한 영역의 위젯을 관리합니다.
  3. RenderViewport: 사용자가 콘텐츠의 일부를 볼 수 있는 보이는 창을 나타냅니다.

다양한 RenderObjectWidget 이해하기

LeafRenderObjectWidget

LeafRenderObjectWidget은 위젯 트리의 리프 노드를 나타내며, 자식 위젯이 없는 위젯입니다. CustomPaint와 같이 자식 위젯을 관리할 필요 없이 자체 렌더링 로직을 처리하는 경우에 사용됩니다.

SingleChildRenderObjectWidget

SingleChildRenderObjectWidget은 하나의 자식을 가지는 위젯에 사용됩니다. 자식의 레이아웃과 렌더링을 직접 조작할 수 있어 제어와 복잡성의 균형을 제공합니다.

MultiChildRenderObjectWidget

여러 자식을 포함하는 위젯에는 MultiChildRenderObjectWidget이 사용됩니다. RowColumn과 같은 위젯은 각 자식의 레이아웃과 위치를 관리하며, 렌더링 프로세스에 더 많은 복잡성과 제어를 추가합니다.

위젯의 해부학: Flutter의 SizedBox 깊이 탐구하기

Flutter 위젯, 특히 SizedBox의 내부 작동 방식을 탐구하면 Flutter 설계의 아키텍처와 계층적 접근 방식에 대해 흥미로운 통찰을 얻을 수 있습니다. 주로 UI를 구성적으로 빌드하는 StatelessWidget이나 StatefulWidget과 달리, SizedBox와 같은 위젯은 레이아웃과 페인팅을 직접 제어하여 렌더링 로직에 대해 고유한 제어를 제공합니다.

SingleChildRenderObjectWidget의 역할

SizedBox의 강력함을 이해하기 위해 정의를 살펴봅시다:

// Flutter 소스 코드
// packages/flutter/lib/src/widgets/basic.dart
class SizedBox extends SingleChildRenderObjectWidget {}

SizedBoxSingleChildRenderObjectWidget의 한 유형으로, StatelessWidget과 같은 일반적인 위젯보다 렌더링 파이프라인에서 더 근본적인 역할을 수행합니다. 이 클래스는 RenderObjectWidget을 확장하며, 주된 목적은 렌더 객체의 생성과 업데이트를 관리하는 것입니다. 이러한 렌더 객체는 Flutter의 렌더링 프로세스의 중추를 형성합니다.

위젯 상속 구조

주요 상속 체인을 분해해 보겠습니다:

abstract class SingleChildRenderObjectWidget extends RenderObjectWidget {}
abstract class RenderObjectWidget extends Widget {}

SingleChildRenderObjectWidgetRenderObjectWidget을 상속받아 단일 자식을 가지는 위젯의 렌더링 세부 사항을 직접 관리하는 역할을 강조합니다.

SizedBox의 내부 동작 방식

SizedBox의 소스 코드를 살펴보면 그 구조와 목적을 이해할 수 있습니다:

class SizedBox extends SingleChildRenderObjectWidget {
  const SizedBox({
    super.key,
    this.width,
    this.height,
    super.child,
  });

  final double? width;
  final double? height;

  
  RenderConstrainedBox createRenderObject(BuildContext context) {}

  BoxConstraints get _additionalConstraints {}

  
  void updateRenderObject(BuildContext context, RenderConstrainedBox renderObject) {}

  
  String toStringShort() {}

  
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {}
}

여기서 주요 함수는 createRenderObject()updateRenderObject()로, RenderConstrainedBox 인스턴스를 생성하고 업데이트합니다.

RenderConstrainedBox와 렌더 객체

RenderConstrainedBoxRenderProxyBox의 서브클래스로, 궁극적으로 RenderBox를 상속받습니다:

class RenderConstrainedBox extends RenderProxyBox {}
class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin<RenderBox> {}

RenderBox란 무엇인가?

RenderBox는 2D 공간에서 사용되는 특정 유형의 RenderObject로, 다음과 같은 역할을 합니다:

  1. 레이아웃 수행: UI의 각 부분의 크기와 위치를 결정합니다.
  2. 페인팅: 시각적 요소를 화면에 렌더링합니다.
  3. 수명 주기 관리: 초기화와 폐기를 포함하여 렌더 트리에서의 역할을 관리합니다.

렌더 객체의 유형

Flutter에는 세 가지 주요 렌더 객체 유형이 있습니다:

  1. RenderBox: 2D 공간에 배치된 위젯을 처리합니다.
  2. RenderSliver: 스크롤 가능한 영역의 위젯을 관리합니다.
  3. RenderViewport: 사용자가 콘텐츠의 일부를 볼 수 있는 보이는 창을 나타냅니다.

다양한 RenderObjectWidget 이해하기

LeafRenderObjectWidget

LeafRenderObjectWidget은 위젯 트리의 리프 노드를 나타내며, 자식 위젯이 없는 위젯입니다. CustomPaint와 같이 자식 위젯을 관리할 필요 없이 자체 렌더링 로직을 처리하는 경우에 사용됩니다.

SingleChildRenderObjectWidget

SingleChildRenderObjectWidget은 하나의 자식을 가지는 위젯에 사용됩니다. 자식의 레이아웃과 렌더링을 직접 조작할 수 있어 제어와 복잡성의 균형을 제공합니다.

MultiChildRenderObjectWidget

여러 자식을 포함하는 위젯에는 MultiChildRenderObjectWidget이 사용됩니다. RowColumn과 같은 위젯은 각 자식의 레이아웃과 위치를 관리하며, 렌더링 프로세스에 더 많은 복잡성과 제어를 추가합니다.

Custom RenderObject를 이용하여 Widget 구현하기

LabeledDivider라는 사용자 정의 Flutter 위젯을 만들어 보겠습니다. 이 위젯은 화면에 가로로 선을 그리고 가운데에 레이블(텍스트)을 표시합니다. 레이블 텍스트, 선의 두께 및 색상에 대한 매개 변수를 사용할 수 있습니다.

Step 1: Create the main() Function

void main() {
  runApp(MaterialApp(
    home: Scaffold(
      appBar: AppBar(
        title: const Text('Labeled Divider Example'),
      ),
      body: const Column(
        children: [
          Text('Above the divider'),
          LabeledDivider(
            label: 'Divider Label',
            thickness: 2.0,
            color: Colors.blue,
          ),
          Text('Below the divider'),
        ],
      ),
    ),
  ));
}

Step 2: Define LabeledDivider Widget

다음으로 LabeledDivider 위젯을 정의해 보겠습니다. 이 위젯은 레이블, 두께, 색상이라는 세분화된 매개변수를 받습니다. 이 위젯에는 자식 위젯이 없으므로 LeafRenderObjectWidget이 베이스 클래스로 적합할 것 같습니다.

class LabeledDivider extends LeafRenderObjectWidget {
  const LabeledDivider({
    super.key,
    required this.label,
    this.thickness = 1.0,
    this.color = Colors.black,
  });

  final String label;
  final double thickness;
  final Color color;

  
  RenderLabeledDivider createRenderObject(BuildContext context) {
    return RenderLabeledDivider(
      label: label,
      thickness: thickness,
      color: color,
    );
  }

  
  void updateRenderObject(BuildContext context, RenderLabeledDivider renderObject) {
    renderObject
      ..label = label
      ..thickness = thickness
      ..color = color;
  }
}

Step 3: Create RenderLabeledDivider

class RenderLabeledDivider extends RenderBox {
  String _label;
  double _thickness;
  Color _color;
  late TextPainter _textPainter;

  RenderLabeledDivider({
    required String label,
    required double thickness,
    required Color color,
  })  : _label = label,
        _thickness = thickness,
        _color = color {
    _textPainter = TextPainter(
      textDirection: TextDirection.ltr,
    );
  }
}

Step 4: Define Private Variables and TextPainter

프라이빗 변수를 선언하고 RenderLabeledDivider에서 TextPainter를 초기화합니다:

class RenderLabeledDivider extends RenderBox {
  String _label;
  double _thickness;
  Color _color;
  late TextPainter _textPainter;

  RenderLabeledDivider({
    required String label,
    required double thickness,
    required Color color,
  })  : _label = label,
        _thickness = thickness,
        _color = color {
    _textPainter = TextPainter(
      textDirection: TextDirection.ltr,
    );
  }
}

Step 5: Create Setters and Getters

렌더 객체는 항상 데이터를 수신하며, 값을 개인 변수로 설정해야 하는데, 이는 세터와 게터를 통해 수행할 수 있습니다.

class RenderLabeledDivider extends RenderBox {
  // 기존 코드...
  set label(String value) {
    if (_label != value) {
      _label = value;
      markNeedsLayout();
      markNeedsSemanticsUpdate();
    }
  }

  String get label => _label;

  set thickness(double value) {
    if (_thickness != value) {
      _thickness = value;
      markNeedsLayout();
    }
  }

  double get thickness => _thickness;

  set color(Color value) {
    if (_color != value) {
      _color = value;
      markNeedsPaint();
    }
  }

  Color get color => _color;
}

Step 6: Complete createRenderObject

LabeledDivider 위젯으로 돌아가서, createRenderObject 메서드를 완성합니다:


RenderLabeledDivider createRenderObject(BuildContext context) {
  return RenderLabeledDivider(
    label: label,
    thickness: thickness,
    color: color,
  );
}

이 메서드는 제공된 프로퍼티를 사용하여 RenderLabeledDivider의 인스턴스를 생성합니다.

Step 7: Complete updateRenderObject

LabeledDivider에서 updateRenderObject 메서드도 완성합니다:


void updateRenderObject(BuildContext context, RenderLabeledDivider renderObject) {
  renderObject
    ..label = label
    ..thickness = thickness
    ..color = color;
}

이 메서드는 위젯이 리빌드될 때 렌더 오브젝트를 새 프로퍼티로 업데이트합니다.

Step 8: Implement performLayout in RenderLabeledDivider

performLayout 메서드를 구현하여 레이아웃을 수행합니다:


void performLayout() {
  _textPainter.text = TextSpan(
    text: _label,
    style: TextStyle(
      color: _color,
    ),
  );
  _textPainter.layout();

  final double textHeight = _textPainter.size.height;
  size = constraints.constrain(
    Size(
      double.infinity,
      _thickness + textHeight,
    ),
  );
}

Step 9: Paint the Divider and Text

paint 메서드를 사용하여 선과 텍스트를 그립니다:


void paint(PaintingContext context, Offset offset) {
  final Paint paint = Paint()..color = _color;
  final double yCenter = offset.dy + size.height / 2;

  // Draw the line
  context.canvas.drawLine(
    offset,
    Offset(offset.dx + size.width, yCenter),
    paint,
  );

  // Draw the text
  final double textStart =
      offset.dx + (size.width - _textPainter.size.width) / 2;
  _textPainter.paint(
    context.canvas,
    Offset(textStart, yCenter - _textPainter.size.height / 2),
  );
}

Step 10: Define describeSemanticsConfiguration

접근성을 위한 시맨틱 정보를 추가하기 위해 describeSemanticsConfiguration을 정의합니다:


void describeSemanticsConfiguration(SemanticsConfiguration config) {
  super.describeSemanticsConfiguration(config);
  config
    ..isSemanticBoundary = true
    ..label = 'Divider with text: $_label';
}

이러한 구현을 통해 RenderLabeledDividerLabeledDivider 위젯의 레이아웃, 페인트, 시맨틱 정보를 표시할 수 있으며 프로퍼티의 변경에 반응합니다.

결과 화면
결과 화면

엘리먼트와 렌더 오브젝트 트리의 분리

Flutter에서 엘리먼트와 렌더객체 트리를 분리하는 것은 프레임워크의 성능, 명확성 및 유형 안전성을 향상시키는 중요한 설계 결정입니다.

Flutter에서는 레이아웃 변경이 발생하면 레이아웃 트리의 관련 부분, 즉 렌더객체 트리만 업데이트하는 것이 효율적입니다. 구성적 특성으로 인해 엘리먼트 트리는 일반적으로 더 많은 노드를 포함합니다. 두 트리가 결합된 경우 레이아웃을 업데이트하려면 엘리먼트 트리의 불필요한 노드를 수없이 거쳐야 하므로 성능 비효율성이 발생합니다. Flutter는 이러한 트리를 분리하여 레이아웃 업데이트에 더욱 집중하고 효율적으로 작업할 수 있도록 하여 전반적인 앱 성능을 향상시킵니다.

또한 이러한 분리는 보다 정확한 책임 분담에도 기여합니다:

  • 위젯 프로토콜: 위젯(또는 엘리먼트) 트리는 UI의 구조와 구성을 설명하는 데 중점을 둡니다. UI의 모양을 나타내는 불변 위젯을 다룹니다.
  • 렌더 오브젝트 프로토콜: 반면에 렌더 오브젝트 트리는 실제 레이아웃과 페인팅에 관한 것입니다. 여기서 렌더 오브젝트는 변경 가능하며 UI의 기하학적 및 시각적 측면을 처리합니다.

이렇게 명확하게 분리하면 두 프로토콜의 API 표면이 단순해집니다. 위젯은 단순하고 선언적인 상태로 유지할 수 있고, 렌더 오브젝트는 레이아웃과 렌더링 세부 사항에 집중할 수 있습니다. 이렇게 하면 두 시스템의 복잡성이 줄어들고 버그의 위험이 낮아지며 테스트 부담이 완화됩니다.

마지막으로, 분리는 레이아웃 프로세스에서 유형 안전성을 향상시킵니다. 렌더 오브젝트 트리는 런타임에 그 자식들이 특정 좌표계에 맞는 올바른 유형인지 확인할 수 있습니다. 예를 들어, 렌더박스는 상자 좌표를 사용하는 렌더 오브젝트를 자식으로 기대합니다.

이와 대조적으로 위젯 트리에 해당하는 엘리먼트 트리는 처리할 수 있는 자식 유형에 대해 더 유연합니다. 좌표계에 대한 걱정 없이 하나의 위젯을 다양한 레이아웃 모델(예: 박스 레이아웃 또는 슬라이버 레이아웃)에서 사용할 수 있습니다. 엘리먼트와 렌더객체 트리를 결합하면 각 위젯이 자식의 특정 레이아웃 제약 조건과 좌표계를 인식해야 하므로 추가적인 유형 검사가 필요하고 위젯 디자인이 복잡해집니다.

이 측면은 또한 소프트웨어 엔지니어링의 기본 원칙인 '관심사 분리'를 강조합니다.

이 원칙은 Flutter의 아키텍처에 신중하게 구현되어 깔끔하고 효율적인 디자인을 보장합니다.

References.

@beygee
미션 달성을 위해 실험적인 도전부터 안정적인 설계까지 구현하는 것을 즐겨합니다.