내 사각형은 화면 중앙에 존재한다. 어떻게?

MJ Studio
Turing
Published in
30 min readDec 2, 2022

--

프론트엔드 Layout에 대한 Flutter를 이용한 철학적 탐구

TL;DR

  • 프론트엔드의 프레임워크들의 공통된 문제 해결에 있어 비슷한 해결책들
  • Layout은 무엇일까?
  • 프레임워크별로 비슷하면서도 다른 Layout 알고리즘
  • Layout은 왜 비쌀까?
  • UI의 트리 모델들에서 재귀적 Layout이 일어나는 과정
  • Intrinsic Dimension

Table of contents

  1. Intro
  2. Purpose
  3. Layout Overview
  4. Similar tree structure but…
  5. Cost
  6. Recursive tree traversal model
  7. Intrinsic dimension
  8. Where to go?

1. Intro

프론트엔드 개발자로서, 우리의 앱에 존재하는 어떤 직사각형을 유심히 관찰하다 보면, 이걸 도대체 어떻게 우리가 보고있는걸까? 하는 호기심이 들 수 있습니다.

이러한 의문에 대해 A to Z 로 근본적인 탐구를 시작하다 보면, 이 글에 다 담지 못할 정도로 끝도 없이 방대한 내용이 될 것입니다. 적어도 우리의 출력 디바이스에서 임의의 색으로 칠해진 임의의 영역이 빛에 반사되어 우리의 시신경으로 들어와 “보인다”라고 인식하는 과정이 포함되어야 하겠죠.

하지만 단계를 많이 축소하여 개발과 관련된 부분만 분리해낸다면, 대략 다음과 같을 것입니다.

  1. 사각형을 스크린 정중앙에 그리는 프레임워크 API를 사용한다.
  2. 프레임워크가 사각형을 우리가 의도한 위치에 그려준다.
  3. 디바이스에 실행된 우리의 앱에 사각형이 보인다!

이 글은 2를, 그중에서도 오직 사각형이 중앙에 배치되기까지의 Layout 과정만 다룹니다.

2. Purpose

이 글의 본래 목적은 어떤 프론트엔드 프레임워크에도 의존성을 두지 않고 순수하게 Layout, Paint, Rasterize에 대한 내용을 추상적으로 다룸에 있었습니다. 하지만 내용이 방대해짐에 따라 Paint와 Rasterize에 관련된 부분은 삭제하고 Layout의 내용만 남겼습니다.

이 내용들을 어떻게 추상적으로 다룰 수 있을까요?

프론트엔드에서 플랫폼과 무관하게 공통적으로 중요한 개념들이 열거될 수 있다는 것은 참입니다. 여러 프레임워크를 다루다보면 State management, Layout, Paint, Accessibility, Gesture negotiation, Animation 등등의 수많은 개념들이 특정 플랫폼에만 국한되어 있는 것이 아님을 인지하게됩니다.

또한 저는 프론트엔드의 프레임워크를 직접 개발한 사람들이 가장 효율적이고 일반적인 문제해결 방식을 채택했다고 믿습니다. 말하자면 거기서 거기라는 말입니다.

예를 들어, 어떤 UI 프레임워크를 쓰며 개발하든 View hierarchy를 디버깅 툴로 보면 대개 트리 구조입니다. 그것이 UI API를 구성하기에 가장 효율적이라고 증명되었기 때문입니다. 그러한 트리를 다루는 프레임워크의 API를 관찰하면, 우리는 반복적으로 addChild, children, parent 등의 변수명을 보게되거나 Composite, Visitor, Decoration 패턴 등에 익숙해질 수 밖에 없습니다.

다른 예시로는, 리스트에서 같은 타입의 자식들의 변경에 대응하는 방법으로 각 자식마다 개발자가 직접 Key를 설정해주는 흔한 방법은 널리 알려져있습니다. Jetpack Compose, React, Flutter, …

Flutter를 만든발자

이렇게 최대한 공통적인 개념들을 통틀어 설명하며 글을 진행하려 했지만, 결국 구체적인 예시를 타고 들어가며 써짐에 따라 하나의 분석의 대상이 될만한 가이드라인이 필요하다는 생각이 들었고, 그걸 Flutter로 정했습니다.

Android의 legacy View의 몇만줄짜리 코드를 분석하는 것은 피곤한 일이고, React Native는 결국 각 플랫폼의 Native View에 대한 1:1 대응일 뿐이기 때문에 자체적인 렌더링 엔진으로 low level부터 pure Dart code 까지로의 UI 아키텍처를 직접 구성한 Flutter가 가장 만만해보였습니다.

그래도 최대한 글을 프론트엔드의 Layout이라는 것에서 등장하는 여러 프레임워크에서의 공통적인 개념에 초점을 맞춰 추상적으로 풀어내고싶고, 주된 설명은 Flutter의 문제해결 방식에서 따왔습니다.

하지만 Flutter를 몰라도 읽을 수 있는 글입니다.

3. Layout Overview

다시 가볍게 시작해보겠습니다.

Layout에 대한 이해는 프론트엔드 개발자의 소양이자 개인적으로는 Paint보다 훨씬 자세히 알아야 한다고 생각합니다. Paint는 대부분의 프레임워크가 알아서 잘 해주지만, Layout은 개발자 손을 꽤 타기 때문입니다.

결국 지금 궁금한 건 사각형이 왜 디바이스 중간에 위치하게 되었냐입니다.

화면 중앙에 있는 넓이가 있는 사각형이라는 점이 중요합니다. 화면 중앙에 있기 때문에 화면 내부에서 볼 수 있고 넓이가 있기 때문에 Border를 이용해 보여지고 있으니까요.

Layout은 뭘까요?

Layout은 다음과 같은 한마디로 표현될 수 있다고 생각합니다.

Layout = Negotiation between parent and children 🤔

부모와 자식이란 개념은 뜬금없이 어디서 등장했을까요? 이전 단락에서 View hierarchy는 대개 트리 구조를 이룬다고 했습니다. 어떤 UI 프레임워크든 트리 형태의 UI 구조라면 재귀적인 알고리즘으로 자식의 Positioning를 부모가 책임지게 되어있습니다.

애초에 Android의 legacy View API는 ViewGroup이란 자식을 갖는 Container를 의미하는 추상체를 하나 명시한 Composite 패턴입니다.

Layout은 협상입니다.

부모가 자식의 Layout을 책임져야하기 때문에 자식이 똑똑하게 말을 잘들어야 이 과정이 수월합니다. 갑자기 반항적으로 Intrinsic dimension을 무지막지하게 크게 잡아버리거나 overflow를 하려고 들면 상당히 곤란할 것입니다.

게다가 어떤 부모는 자식이 하나가 아니라서 모든 자식들을 고려해야 할 수도 있으며 심지어 어떤 자식은 다른 형제(sibling)의 Layout까지 관심을 가지고 싶어 할 수도 있습니다.

최근 개발자에게 노출된 Layout API의 추세는 다음과 같습니다.

Layout은 여러 방식으로 진행될 수 있지만, 대개 이전의 모바일에서의 Layout은 AndroidConstraintLayout, iOS의 UIKit의 Auto Layout을 생각할 수 있듯이, 각 자식들이 자신들이 배치되어야 할 Position과 Size를 flat한 컨테이너 안에서 parent와 sibling을 모두 알고있다는 가정하에 모순되지 않게, 그리고 부족한 정보가 없게끔 제약을 선언하는 방식이였습니다.

하지만 요즘은 Compose, Swift UI, React, Flutter 등 Declarative한(보통 이건 State -> UI와 더 관련있는 개념이지만) UI 툴킷들이 판을 치는 세상이고 개발자가 작성하는 코드의 구조를 실제 UI의 트리 구조와 비슷하게 나타내는게 패러다임이다보니 flex box 비슷한 형태로 API들이 대체되어왔습니다. Column이나 Row같은게 어디서든 비슷비슷하게 존재합니다.

4. Similar tree structure but…

하지만 프레임워크마다 Layout 알고리즘이 구현된 방식은 당연히 다릅니다.

Case 1 — Mental model difference

이걸 깨달은 순간은 React Native에서 실제 뷰가 가질 수 있는 Size에 따라 다르게 보여져야 하는 테크닉을 깔끔하게 구현하기가 불가능할 때였습니다.

실제 차지할 수 있는 Size에 따라 다르게 보이는 UI

현재 컴포넌트의 크기를 동적으로 계산해야 하는데, onLayout 을 사용해버리면 layout pass를 한 번 더 거쳐야 하는것은 양반이고 그 이후 state를 변경시킬 것이니 rerender까지 이루어지는 난감한 상황입니다. initial render에서는 onLayout이 되기 전이니 뷰를 보여줄 수 없는것은 덤이고 애초에 뭔가 이상합니다.

이는 React의 Layout API가 기본적으로 Constraints란 개념을 strictly하게 정의해두지 않았거나, 이를 외부에서 활용할 수 있도록해주는 API가 없기 때문입니다.

글에서 Constraints이라 일컬어지는 것의 정의는 보통 2D Cartesian Coordinate의 것으로 min/max width/height를 의미합니다.

Jetpack ComposeFlutter에선 Layout 알고리즘에서 부모가Constraints을 자식에게 내려보내고 자식은 이에 맞게 크기를 결정해 부모에게 전달합니다. 그럼 다시 부모가 자식의 geometry를 이용해 배치해주는 식입니다.

또한, 개발자 친화적으로 Constraints를 활용할 수 있게BoxWithConstraints 란 것이 있고, LayoutBuilder도 있습니다. 위의 상황을 구현하기가 깔끔하고 간편합니다.

Case 2 — Usage difference

또다른 상황으로는 Flutter 개발자라면 편리하게 사용했을 Padding, Center , Align같은 컴포넌트들을 React에서 개발하려고 하면 웬지 모를 이질감이 든다는 것입니다.

이건 React의 Layout Constraints가 transitivity하게 전달되지 않기 때문입니다. 쉽게 말해, 바로 한 단계 밑의 자식이 아니면 Constraints를 전달할 수 없다는 뜻입니다. 이에 반해 Flutter에서는 조상-후손간에 Constraints가 transitivity한 방식으로 전달되기 때문에 Jetpack Composemodifier나 React의 style같은 속성이 따로 필요가 없게 됩니다.

React Native에서 Center 를 구현해보겠습니다.

export const Center = ({ style, ...rest }: ViewProps) => {
return <View style={
[style, { justifyContent: 'center', alignItems: 'center' }]
} {...rest} />;
};

아이디어는 훌륭해보입니다. 이제 실제로 써볼까요?

const redBoxElement = 
<View style={{ width: 100, height: 100, backgroundColor: '#f00' }} />;
return <Center>{redBoxElement}</Center>;

기대와는 다르게 화면 정중앙이 아닌 가로에서만 중앙에 위치하게 되었습니다.

이제 Flutter로 작성해보겠습니다.

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Container(
width: 100,
height: 100,
color: const Color(0xFFFF0000),
),
),
);
}

이런 차이가 발생하는 이유는 React Native의 레이아웃이 flex box 기반이여서 애초에 다르기도 한 점도 있지만, Flutter가 tight 혹은 loose한 Constraints에 대해 각 RenderObject의 구현체들의 레이아웃 알고리즘이 다르게 반응하는 시스템을 견고하게 만들어두었기 때문입니다.

기본적으로 Center라는 위젯은 내부적으로 RenderPositionedBox라는 RenderBox(Flutter에서 Cartesian coordinate를 이용한 Layout의 가장 근간이 되는 컴포넌트, 엄밀히 말하면 RenderShiftedBox를 상속하지만)를 구현한 RenderObject가 사용되어 렌더링됩니다. RenderBox에 대한 설명은 이곳에 자세히 되어있습니다.

그리고 RenderPositionedBox 는 Layout 에서 자신이 차지할 수 있는 모든 영역을 차지하고 자신의 자식을 중앙(정해진 alignment지만 Center니까 중앙이라고 하겠습니다.)에 배치합니다. 코드를 보면 아시겠지만, 무조건 모든 영역을 차지하는건 아닙니다.

따라서 우리가 이러한 Constraints가 주어졌을 때 Center라는 위젯은 차지할 수 있는 모든 영역을 차지하며 자식 위젯을 중앙에 배치한다는 점(혹은 그렇지 않을 수도 있다는 점)을 우린 사전에 알고있어야 한다는 점입니다.

심지어 Flutter의 Docs엔 다음과 같이 설명되어있습니다.

If you try to guess, you’ll probably guess wrong. You can’t know exactly how a widget behaves unless you’ve read its documentation, or studied its source-code.

The layout source-code is usually complex, so it’s probably better to just read the documentation. However, if you decide to study the layout source-code, you can easily find it by using the navigating capabilities of your IDE.

각 위젯들이 자기멋대로 동작할 수 있으니 Docs도 꼼꼼히 챙겨보고 필요하면 소스코드도 보면서 공부하라는 말입니다.

Flutter의 이런 Layout 모델은 종합적으로 따져보았을 때, 편리하고 고도화 되었다고 생각하지만, 비교적 이해하기 어렵고 알아야 할 것이 더 많습니다. 기존에 접해보지 않은 형태라면 Unbounded height에 expand를 시도하는게 대체 무슨 에러인지 혼돈이 올 수 있습니다.

https://medium.com/flutter-community/error-handling-in-flutter-98fce88a34f0

이처럼 프론트엔드의 프레임워크들끼리 선언적인 UI 방식이든 비슷한 트리 구조이든 결국엔 내부적인 Layout 알고리즘의 차이때문에 이질적인 상황들이 흔하게 발생하는 것을 살펴보았습니다.

5. Cost

우리가 UI 프레임워크를 이용해 UI를 그리는 코드에서 사용되는 클래스들은 대부분 청사진입니다. 청사진은 정말 객체 그 자체이기 때문에 가볍습니다. 트리를 순회하는 것도 그렇습니다. C++ 로 알고리즘을 푼다면 시간복잡도 O(K), (K<N^2)을 가지는 트리 순회 문제의 N이 100,000이라해도 1.1초안에 통과합니다.

우리는 컴포넌트를 10만개나 만드는 것도 아닙니다. 게다가 프론트엔드의 UI 트리 순회 알고리즘들은 예외적이지 않은 경우 대부분O(N) 을 보장합니다(적어도 전 그래야 한다고 믿습니다).

근데 왜 Layout 알고리즘은 트리 순회의 횟수에 민감할까요? 왜 한 번의 프레임에서 하나의 노드들은 한 번씩만 방문되어야 한다는 Single pass traversal는 중요한가요?

이 질문에 답하려면 왜 Layout이 비싼 cost를 갖는 phase인지를 알아야합니다. Flutter DevTools의 Timeline event를 잠깐 첨부하겠습니다.

Flutter DevTools의 Timeline event

이건 단순한 사각형을 그리는 예제이기 때문에 phase들이 잡아먹는 시간이 고만고만하지만, 한눈에 보기에는 Layout이 그렇게 큰 영역을 차지하지 않는 것처럼 보입니다.

그런데도 Layout이 비싼 cost를 가지는 이유는 Layout이 일어나면 Paint와 Rasterize가 필수불가결하게 뒤따르기 때문입니다.

색을 빨간색에서 파랑색으로 바꾸는 것만 Paint가 아닙니다. 픽셀의 단위로 보자면 컴포넌트의 위치와 크기가 변경되면 결국 어떤 영역은 새롭게 그려져야 하기 때문에 Paint와 Rasterize가 뒤따르는 것입니다.

예시로, FlutterText의 내부 구현체인 RenderParagraph를 살펴보겠습니다.

identical, metadata, paint, layout으로 이루어진 RenderComparsion 열거자의 설명은 다음과 같이 되어있습니다.

The values in this enum are ordered such that they are in increasing order of cost. A value with index N implies all the values with index less than N. For example, layout (index 3) implies paint (2)

즉, 뒤로 갈수록 cost가 증가하는 순서이고 layout이 가장 크다는 것입니다.

RenderParagraph의 텍스트 문자열은 setter가 다음과 같이 구현되어 있습니다.

내부 구현 코드를 첨부할 때 필요없는 부분은 생략됩니다.

set text(InlineSpan value) {
...
switch (_textPainter.text!.compareTo(value)) {
case RenderComparison.identical:
case RenderComparison.metadata:
return;
case RenderComparison.paint:
...
markNeedsPaint();
break;
case RenderComparison.layout:
...
markNeedsLayout();
break;
}
...
}

layout이 변경되어야 할 시점인데 이 객체가 렌더링 파이프라인에서 dirty 해졌다고 설정해주는 markNeedsXX 함수가 layout에 대해서만 호출되고 있습니다. 텍스트의 레이아웃이 변하면 다시 텍스트를 그려주는 Paint과정도 필요할 것 같은데요. 필요없기 때문에 markNeedsPaint를 다시 호출해주지 않은 것입니다. 즉, Layout phase는 Paint가 뒤따른다는걸 다시 확인할 수 있습니다.

게다가 Layout은 자기혼자 변하지 않습니다. 자식의 Layout 변화는 부모의 Layout recalculation과정을 동반시키기도 합니다. 예를 들어, flex box에서 flex-wrap 같은 동작의 경우, 자식의 width가 커지면 변하면 자식이 다음 줄로 가야 할 수도 있으므로 자식의 layout 과정이 부모까지 layout 을 시켜버립니다.

여러 프레임워크에선 이러한 Layout이 가지는 성능적 문제를 해결하기 위해 각기 다른 방법을 취하지만, Flutter에서는 Layout API 자체에서 short-circuit 의 optimization을 많이 최적화했습니다.

예를 들어, 자식이 온전히 부모에게 전달받은 Constraints에 의해 크기가 결정될 수 있다면 프레임워크에서 레이아웃 단계에서 최적화를 해주거나 Constraints이 tight해서 자식의 Layout이 불변으로 고정되는 경우 Layout pass를 건너뛰기도 합니다. 혹은 부모가 자식을 layout 시키는 함수에서 부모가 자식의 Size를 사용해서 Layout과정이 진행되는지 플래그 변수를 전달할 수도 있습니다. 다음의 코드에서 parentUsesSize 가 그것을 의미합니다. 여기에 false를 전달한다면 내부적인 최적화가 진행됩니다.

 child.layout(childConstraints, parentUsesSize: true);

이 부분은 이 글에서 더 자세히 살펴볼 수 있습니다.

결국 Layout 알고리즘이 트리 순회에 민감해야 할 이유는 O(N)이여도 붙는 상수가 너무 크기 때문이라고도 볼 수 있습니다. 이 상수가 작다면 트리 순회를 두 번을하든 거꾸로하든 간선을 쪼개든 문제가 되지 않을 것입니다.

실제로 Flutter에는 트리를 Dry 하게 순회하는 테크닉이 있습니다. computeDryLayoutFlutter 2.0에 도입된 비교적 최신의 API인데, 그 이름이 의미하듯 wet한 실제 Layout 과정은 단 한번만 하고 순수하게 레이아웃을 결정할 수 있는 dry한 Layout 과정을 시뮬레이션하는 함수입니다. 내부적으론 Intrinsic을 결정하는 쪽에서 주로 사용됩니다. 동일한 BoxConstraints 객체에 대해서는 동일한 결과를 반환하는 메모이제이션도 내부적으로 쓰여 효율적입니다.

6. Recursive tree traversal model

Flutter가 내부적으로 3개의 트리를 만들어 build 과정에서 element들을 어떻게 순회하는지는 이 글이 글을 보면 이해할 수 있습니다.

우리가 관심있는 것은 Layout인데, 사실 전체적인 숲을 보지 않고도 순회된다는 느낌만 가지고 가면서 어떤 한 부모와 자식간의 협상 과정만 살펴보도록 합시다.

Layout 과정은 대개 다음과 같은 공식이라고 생각할 수 있습니다.

(Position, Size) = f(Constraints, Children)

Constraints과 자식들(0, 1, N개가 될 수 있는)을 인자로 넣어 Layout의 결과물인 Position과 Size를 반환받습니다.

실제로 Center 를 모방한 MyCenter 라는 부모 레이아웃을 만들 것입니다.

class MyCenter extends SingleChildRenderObjectWidget {
...
}

class _RenderMyCenter extends RenderShiftedBox {
_RenderMyCenter({RenderBox? child}) : super(child);

@override
Size computeDryLayout(BoxConstraints constraints) {
return Size(constraints.maxWidth, constraints.maxHeight);
}

@override
void performLayout() {
Size parentSize = Size(constraints.maxWidth, constraints.maxHeight);

child!.layout(constraints.loosen(), parentUsesSize: true);
final BoxParentData childParentData = child!.parentData! as BoxParentData;
childParentData.offset = Offset(
(parentSize.width - child!.size.width) / 2,
(parentSize.height - child!.size.height) / 2
);

size = parentSize;
}
}

이를 pesudo 코드로 나타내면 다음과 같습니다.

class MyCenter {
layout:
1. set parent size expand as possible.
2. call layout of child.
3. position child at center.
}
}
그냥 Center 쓴거 아닙니다.

간단하죠?

이런 단순한 위젯은 어렵지 않습니다. 그러나 Column이나 Row같이 자식이 가질 수 있는 Flex 값을 고려해야 하는 Layout 알고리즘들이나 Cartesian Coordinate가 아닌 Polar Coordinate를 사용하는 Layout이라든지 하는건 조금 더 복잡할 수도 있을 것입니다.

Custom Column을 만드는 방법은 이 동영상에서 자세히 확인해볼 수 있습니다.

이처럼 레이아웃에서 부모와 자식의 협상 과정에서는 크게 어려운게 없습니다. 트리에서 재귀적으로 부모가 자식들에게 Constraints를 전달하고, 자식은 그것에 응답하고 부모는 응답한 정보를 기반으로 자식들을 배치해줍니다.

물론 이는 Flutter에서 이야기이고 프레임워크에 따라 조금씩 상이할 수 있습니다. 그러나 트리에서의 부모와 자식간의 협상과정이란 점은 크게 다르지 않다고 생각합니다.

이에 관련해서 Flutter Docs엔 재밌는 예시가 있습니다.

더 흥미로운점은, 이런 재귀적인 협상 과정이 비단 Layout에서만 일어나는 일은 아니라는 점입니다. Gesture handling(Hit testing), Accessibility, Paint 등에서도 이러한 트리 구조를 이용한 부모와 자식간의 재귀적인 소통은 흔하며 비슷한 구조를 띕니다.

7. Intrinsic dimension

앞 단락에서 예외의 경우를 빼고는 트리 순회가 대개 O(N)에 이루어진다고 언급했습니다. 그럼 이 예외의 경우는 무엇일까요?

Intrinsic dimension은 무엇일까요?

Intrinsic은 한국말로 ‘고유한’이라는 뜻입니다. 그러니까, 부모가 자식과 Layout에 대한 협상을 진행해야 하는데 자식이 고유한 width나 height를 가져가려고 해서 협상 진행이 까다로워진다는 뜻입니다.

이렇게 고유한 dimension을 갖는 컴포넌트는 대표적으로 텍스트와 로컬 asset으로 렌더링되는 이미지가 있습니다.

네트워크 통신을 이용해 이미지를 보여주는 코드는 Size를 지정해주지 않으면 0 x 0이 되어 아무것도 보이지 않지만 로컬 asset을 이용한 이미지는 Size를 지정해주지 않아도 알아서 적절한 크기로 렌더링되는 경험을 해보셨나요?

바로 로컬 파일로 존재하는 이미지는 이미 우리가 그 이미지 파일의 dimension을 알고 있기 때문에 보통 프레임워크에서 그만큼 Intrinsic width/height를 지정해서 삽입해줍니다.

예시로 AndroidImageViewonMeasure 에서 자신의 크기를 결정해야 하는데, Drawable 객체로부터 intrinsic dimension를 가져옵니다. 좀더 파고들어가다보면 Resources로 부터 파일을 Drawable객체로 초기화하는 과정에서 Color, Animated, Ninepatch 가 아니라면 결국 BitmapDrawable을 반환하는 코드를 찾을 수 있었습니다. 그리고 BitmapDrawable은 단순하게 자신의 dimension을 반환하는 함수를 가지고 있으니(물론 리소스를 불러오는 과정에서 screen density가 고려되어 Bitmap의 크기는 scale될 수 있습니다.) 모든 비밀이 밝혀졌습니다.

class BitmapDrawable ...

@Override
public int getIntrinsicWidth() {
return mBitmapWidth;
}

@Override
public int getIntrinsicHeight() {
return mBitmapHeight;
}

Flutter에서의 Text에 Intrinsic dimension이 사용되는 예시를 살펴보겠습니다.

Flutter에선 Intrinsic dimension에도 minimum intrinsic width/height, maximum intrinsic width/height같이 boundary를 지정해두었습니다.

이걸 Text에서 쉽게 생각하자면 다음과 같습니다.

Minimum Intrinsic Width: 위젯의 width가 이것보다 작아지면 의도된 대로 그려질 수 없는 최소한의 마지노선

보통 이건 Text의 문자열에서 가장 넓이가 넓은 문자의 넓이가 됩니다.

Maximum Intrinsic Width: 위젯이 width가 이것 이상이 되고 아무리 더 커진다고 해도 높이가 줄어들지 않는 width

보통 이건 Text의 문자열이 한 줄에 모두 들어갔을 때의 넓이가 됩니다.

Height는 일반성을 잃지않기 때문에 생략합니다.

이 예시를 보겠습니다.

class _MyHomePageState extends State<MyHomePage> {
final key = GlobalKey();

@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
print((key.currentContext?.findRenderObject() as RenderBox).size);
});
}

@override
Widget build(BuildContext context) {
// Center
return Scaffold(
appBar: AppBar(title: const Text('Layout is difficult')),
body: Container(
width: 200,
alignment: Alignment.centerLeft,
color: const Color(0x66FF0000),
child: LayoutBuilder(builder: (context, constraints) {
print(constraints);
return Text(key: key, 'Hello, World!');
}),
),
);
}
}
flutter: Size(85.0, 16.0)
flutter: BoxConstraints(0.0<=w<=200.0, 0.0<=h<=63.0)

LayoutBuilder에 기록된 maxWidth는 200인것에 반해 텍스트는 고작 85를 차지했습니다. 이것이 바로 우리의 Text의 max intrinsic width가 되는 것입니다.

가장 큰 문자인 W 의 width가 알고싶습니다.

TextPainter textPainter = TextPainter(
text: const TextSpan(text: 'W'),
textDirection: TextDirection.ltr,
)..layout();
print(textPainter.size);
flutter: Size(14.0, 16.0)

이제 우리는 Hello, World! 라는 문자열을 담고있는 Text의 Minimum Intrinsic Width가 14라는 것을 알게 되었습니다!

그래서 Intrinsic dimension에 대한 개념은 알겠는데, 이게 왜 Layout 알고리즘에서 난해한 부분이 될까요?

우리의 UI는 트리 구조로써 root부터 leaf까지 크기에 대한 Constraints를 전달하며 자식들의 Size를 필요에 따라 제한해야 하는데, 갑자기 자식이 자신은 특정 Size이하보다 못작아진다고 반항을 하는 경우이기 때문입니다.

협상 테이블은 복잡해지고 어떤 프레임워크냐에 따라 그 문제를 해결하는 방식이 달라지기도 합니다(애초에 개발자가 잘해야 되는 문제라고 생각하긴 합니다). 그냥 Clip을 시켜버릴 건지, 자식이 하고싶은대로 냅두든지, Layout은 가두되 Paint는 가능하게 할건지 프레임워크, 컴포넌트, API에 따라 모두 다르기 때문에 유의가 필요합니다.

이러한 예시를 하나하나 드는건 지엽적이고 예측하기 힘든 일이므로 개발자들은 알잘딱하게이러한 상황들을 핸들링해야 합니다.

FlutterIntrinsicHeight, IntrinsicWidth는 이런 협상과는 조금 다른 개념입니다. 단순히 childchild의 Intrinsic size로 고정시켜버리는 역할밖에 안하기 때문입니다.

클래스 구현은 다른것들에 비해 상대적으로 짧지만 오용할시에 최악의 경우 트리 순회가 O(N²)까지 느려지는 결과를 발생시킵니다.

This class is relatively expensive, because it adds a speculative layout pass before the final layout phase. Avoid using it where possible. In the worst case, this widget can result in a layout that is O(N²) in the depth of the tree. — Docs

하지만 이건 정말 최악의 경우입니다. 자식의 Intrinsic Height를 알아야하는데 이 자식이 또 Intrinsic Height를 자신의 자식들에게 물어보고… 하는 과정이 중첩되고 중첩되어야 할 것입니다.

Where to go?

여기까지 프론트엔드에서 Layout이란 phase에서 일어나는 일을 간략하게 주로 Flutter를 예시로 알아보았습니다.

글에선 많이 소개하지 못했지만 앞서 언급한 내용들 말고도 Layout 알고리즘을 복잡하게 만드는 요소는 너무나 다양합니다. 애니메이션, Text의 line breaking, direction 이라든지 레이아웃 자체에서 제약을 복잡하게 관리하기 때문에 불가피해지는 경우도 존재합니다. Intrinsic Dimension은 그저 그중 하나일 뿐입니다.

원래 목적은 이 사각형이 왜 화면에 실제로 저 색을 가지고 그려지는 것이였으나, 여기까지의 내용밖에 담지 못했습니다. Layout과정을 이해한 후에 Paint와 Rasterize까지 공부해보는 것도 좋은 경험이 될 것 같습니다.

Skia Docs까지 실제로 C++ 코드로 짜서 예제를 넣어두려고 켜놨는데 제 할당되지 못한 SkCanvas들은 슬플것입니다. 어쩌면 다음 글의 주제가 될 수도 있겠습니다.

Turing의 앱개발자 Mystic이였습니다.

글 읽어주셔서 감사합니다.

— — — — — — — — — — — — — — — — — — — —

--

--