dioxus-compose 가이드

레퍼런스

컴포저블 레퍼런스

애플리케이션이 쓸 수 있는 모든 컴포저블과, 그 프로퍼티, 기본값, 그리고 디자인 시스템에 맡기는 부분입니다. 목록은 닫혀 있습니다. dioxus-compose의 prelude가 내보내는 것이 전부이고, 렌더러는 그 밖의 어떤 것도 해석하지 않습니다. 알지 못하는 위젯 종류는 프로세스를 죽이는 대신 ProtocolError 이벤트가 됩니다.

prelude가 주는 것

import 한 줄이면 위젯, 역할, 훅, 시그널, 매크로가 모두 들어옵니다.

rust
use dioxus_compose::prelude::*;

컴포저블 33개와 역할 enum들, Paint, Color, Theme, DesignSystem, LaunchBuilder, Message, show_message, use_window_size, 그리고 rsx!가 펼쳐지며 참조하는 Dioxus 항목들입니다. dioxus-core, dioxus-hooks, dioxus-signals를 따로 의존성에 추가할 필요는 없습니다. prelude가 매크로가 찾는 이름 그대로 다시 내보냅니다. Box가 서른네 번째이고, prelude가 일부러 빼 놓은 하나입니다.

Box가 아니라 dioxus_compose::Box로 쓰세요

dioxus-core 0.7의 rsx! 확장은 내부적으로 수식되지 않은 Box<T>를 참조합니다. Compose의 Box를 glob prelude로 내보내면 std::boxed::Box를 가려서 그 확장이 깨지므로, prelude는 일부러 빼 놓았습니다. 타입 자체는 크레이트 루트에서 Box라는 이름으로 내보내고 있으므로(러스트 아이템 이름은 ComposeBox입니다), 업스트림이 자기 쪽을 수식할 때까지는 rsx! 안에서 경로를 붙여 쓰면 됩니다.

rust
rsx! {
    dioxus_compose::Box {
        fill_max_width: true,
        alignment: Alignment::Center,
        Text { text: "가운데" }
    }
}

Modifier 속성

Compose에서 Modifier가 어떤 컴포저블에나 붙듯이 여기서도 그렇습니다. 이 페이지의 모든 위젯이 아래 열세 가지를 전부 받습니다. Text와 Spacer도 마찬가지이므로, 위젯마다 다시 적지 않습니다. 예외는 Separator 하나뿐인데, 프로퍼티가 하나인 단축 표현이라 color만 받습니다.

속성타입기본값효과
weightf32없음Row나 Column 안에서 남은 주축 공간의 몫.
fill_max_widthboolfalse가능한 너비를 모두 차지합니다.
fill_max_heightboolfalse가능한 높이를 모두 차지합니다.
widthf32 dp없음고정 너비.
heightf32 dp없음고정 높이.
paddingf32 dp없음네 변에 같은 여백을, 직접 고른 dp로.
padding_roleSpaceRole없음같은 여백을, 디자인 시스템이 고른 dp로. 이쪽이 낫습니다.
backgroundPaint없음위젯 뒤의 채움.
shape_roleShapeRole없음반지름을 디자인 시스템이 정하는 모서리.
corner_radiusf32 dp없음직접 고른 dp의 모서리 반지름.
border_widthf32 dp없음테두리의 절반. border_color까지 있어야 그려집니다.
border_colorPaint없음나머지 절반.
elevationf32 dp없음디자인 시스템이 정했을 기본 높이를 덮어씁니다.

설정하지 않음은 0이 아닙니다. 쓰지 않은 Modifier는 0이 아니라 아무것도 보내지 않습니다. 여백이 없는 것과 여백이 0인 것은 화면에서 같아 보이지만, 그중 하나만 뮤테이션 비용을 치러야 하기 때문입니다. 슬롯을 공유하는 쌍이 셋 있어서 둘 다 의미 있게 설정할 수는 없습니다. padding과 padding_role, shape_role과 corner_radius, 그리고 border_width와 border_color입니다. 마지막 쌍은 하나의 Modifier이고 양쪽이 모두 필요합니다.

Clickable

와이어 포맷에는 Clickable Modifier가 있고 렌더러도 그것을 Compose 체인에 넣지만, 타입이 붙은 위젯 중에 이것을 프로퍼티로 노출하는 것은 없습니다. 지금 클릭은 Button이나 NavigationItem에서 나옵니다. Clickable에 닿으려면 소문자 엘리먼트 형태 (card { onclickable: move |_| ... })를 써야 하는데, 이 저장소의 어떤 샘플도 그렇게 하지 않으므로 위젯 API의 일부라기보다 검증되지 않은 것으로 보시기 바랍니다.

역할 어휘

위젯은 리터럴이 아니라 역할을 내보냅니다. 역할은 일의 이름("강조색", "제목 단", "중간 모서리")이고, 렌더러가 활성 디자인 시스템의 토큰 표에 대고 그것을 해석합니다. 하나의 rsx! 트리가 이 기계에서는 Material 3로, 저 기계에서는 Cupertino로 나오는 이유가 이것입니다. 표가 어떻게 정해지는지는 디자인 시스템에 있습니다.

Paint와 Color

색을 받는 자리의 타입은 언제나 Paint이고, 역할이거나 리터럴 ARGB 값입니다.

rust
Paint::Role(ColorRole::Primary)          // 디자인 시스템이 해석합니다
Paint::Literal(Color::rgb(0x1b6ef3))    // 불투명, 0xRRGGBB
Paint::Literal(Color::argb(0x80ff0000))  // 0xAARRGGBB

리터럴은 탈출구입니다. 디자인 시스템이 바뀌어도 그대로 남는데, 그것이 바라는 바일 때도 있고(브랜드 색), 대개는 아닙니다(밝은 페이지에서는 읽히고 어두운 페이지에서는 사라지는 색).

ColorRole: 23개의 역할

잉크를 가리키는 역할에는 그 잉크가 얹히는 면이 짝으로 붙어 있습니다. OnPrimary는 Primary로 채운 것 위의 잉크이고, 표 전체가 그렇습니다.

묶음역할쓰임
강조Primary, OnPrimary, Secondary, OnSecondary, Tertiary, OnTertiary세 개의 강조색. 눈이 첫 번째, 두 번째, 세 번째로 가는 곳.
페이지Background, OnBackground페이지 자체와 그 위의 잉크.
면Surface, OnSurface, SurfaceVariant, OnSurfaceVariant, SurfaceContainer읽는 면. SurfaceVariant의 잉크는 흐린 쪽이라 본문보다 캡션과 레이블에 맞습니다.
컨테이너PrimaryContainer, OnPrimaryContainer, SecondaryContainer, OnSecondaryContainer, TertiaryContainer, OnTertiaryContainer각자의 강조색과 친척으로 읽히는 조용한 채움. 각각 자기 잉크를 가지고 있고, 그 잉크는 본문 대비 기준까지 지킵니다.
선Outline, OutlineVariant테두리와 가는 선, 진한 쪽과 조용한 쪽.
오류Error, OnError무엇이 잘못되었는지, 그리고 파괴적인 동작의 레이블.

패널이 페이지 위에서 보여야 할 때 집어야 하는 것은 SurfaceContainer입니다. Material 3는 Surface와 Background에 일부러 같은 값을 주고 깊이를 톤 컨테이너로 표현하므로, Background 페이지 위에 Surface로 채운 패널은 제 크기로, 제 색으로 그려지고도 보이지 않습니다. 지원하는 모든 디자인 시스템이 페이지 위에서 보이게 하겠다고 약속하는 역할이 SurfaceContainer이고, 그 위에 읽히는 것이 OnSurface입니다.

세 컨테이너가 서로 구별된다고는 약속하지 않습니다. Material 3의 기본 primary 컨테이너와 secondary 컨테이너는 한 팔레트의 이웃한 톤입니다. 한눈에 갈라지는 채움 셋이 필요하다면 그 일을 해 주는 것은 tertiary 쌍입니다.

TypeRole: 아홉 단

단쓰임
Display화면에서 가장 큰 것. 한 번만 씁니다.
Headline화면의 제목.
Title구역의 제목, 바의 제목.
Subtitle제목 아래 한 줄.
Body본문. 기본 단입니다.
BodyStrong무게를 실어야 하는 본문.
Label버튼의 글자, 칩, 필드 레이블.
Caption읽을 수 있는 가장 작은 단. 시각, 각주.
Mono고정폭. 코드, 경로, 자리를 맞춰야 하는 숫자.

ShapeRole과 SpaceRole

ShapeRole은 None, ExtraSmall, Small, Medium, Large, Full입니다. dp 단위 반지름은 디자인 시스템의 답이고, Full은 숫자가 아니라 캡슐을 뜻합니다.

SpaceRole은 None, Xs, Sm, Md, Lg, Xl, Xxl입니다. 모든 위젯에 padding_role로, Row와 Column에는 space_role로도 나타납니다. 밀도는 디자인 시스템마다 다르고, 간격을 dp 숫자가 아니라 역할로 적는 이유가 그것입니다.

IconRole

아이콘은 플랫폼의 아이콘 이름이 아니라 쓰임으로 지목합니다. 의미는 열세 가지입니다. Back, Forward, Close, Search, Add, Check, Settings, More, Home, List, Inbox, Menu, History. 하나의 선언이 Cupertino에서는 SF Symbols로, Material 3에서는 Material Symbols로 나오게 하는 것이 이 역할입니다.

뒤의 둘은 목적지가 아니라 창 자신의 막대가 필요로 하는 것입니다. Menu는 창이 보여줄 자리가 없는 것들로 들어가는 입구이고, History는 이미 지나간 것으로 돌아갑니다. 둘 다 List로는 말할 수 없습니다. 그것은 무언가의 목록이지 더 많은 것으로 가는 입구가 아니기 때문입니다.

레이아웃과 텍스트 역할

enum변종쓰이는 곳
ArrangementStart, Center, End, SpaceBetween, SpaceAround, SpaceEvenlyRow와 Column의 주축
AlignmentTopStart, TopCenter, TopEnd, CenterStart, Center, CenterEnd, BottomStart, BottomCenter, BottomEndRow와 Column의 교차축, Box의 양축
TextAlignStart, Center, End, JustifyText
TextOverflowClip, Ellipsis, Visiblemax_lines와 함께 Text
ButtonVariantFilled, Tonal, Outlined, TextButton
MessageDurationShort, LongMessage
WindowSizeClassCompact, Medium, Expandeduse_window_size

레이아웃

Column과 Row

세로로 쌓는 것과 가로로 쌓는 것입니다. 주축의 배치는 arrangement, 자식 사이의 간격은 spacing 또는 space_role, 교차축은 alignment가 정합니다.

프로퍼티타입기본값효과
arrangementArrangement없음주축을 따라 자식을 어떻게 분배할지.
spacingf32 dp없음자식 사이의 간격을 직접 고른 dp로.
space_roleSpaceRole없음같은 간격을 디자인 시스템이 고른 dp로.
alignmentAlignment없음자식의 교차축 정렬.
childrenElement필수쌓이는 내용, 순서대로.
rust
rsx! {
    Column {
        fill_max_width: true,
        padding_role: SpaceRole::Md,
        space_role: SpaceRole::Sm,
        Text { text: "오늘", type_role: TypeRole::Title }
        Row {
            fill_max_width: true,
            arrangement: Arrangement::SpaceBetween,
            alignment: Alignment::CenterStart,
            Text { text: "걸음" }
            Text { text: "8,421", type_role: TypeRole::BodyStrong }
        }
    }
}

디자인 시스템이 정하는 것: SpaceRole::Md가 몇 dp인지, 그리고 Title이 해석되는 타입 사다리.

Box

겹쳐 놓는 컨테이너입니다. 자식이 서로 위에 쌓이고 alignment로 자리를 잡습니다. 자기 것으로는 alignment와 자식만 받습니다. dioxus_compose::Box로 쓰세요.

ScrollColumn

내용 전체에 세로 스크롤을 붙인 것입니다. Modifier와 자식 말고 자기 프로퍼티가 없는데, 스크롤 위치가 렌더러의 것이기 때문입니다. 스크롤은 러스트까지 오지 않고, 오프셋을 읽거나 설정할 수 있는 프로퍼티도 없습니다. 내용의 양이 정해져 있을 때 쓰시고, 긴 목록이라면 화면에 있는 것만 만들어 내는 LazyColumn을 쓰세요.

rust
rsx! {
    ScrollColumn {
        fill_max_width: true,
        fill_max_height: true,
        padding_role: SpaceRole::Md,
        for paragraph in article() {
            Text { key: "{paragraph.id}", text: paragraph.body }
        }
    }
}

ScrollRow

같은 컨테이너를 옆으로 눕힌 것으로, 내용 전체에 가로 스크롤을 붙입니다. ScrollColumn처럼 Modifier와 자식만 받고, 스크롤 위치는 렌더러의 것입니다. 필터 한 줄이나 카드 몇 장처럼 양이 정해진 띠에 쓰시고, 긴 가로 목록이라면 LazyRow를 쓰세요.

rust
rsx! {
    ScrollRow {
        fill_max_width: true,
        for album in recent_albums() {
            Card { key: "{album.id}", width: 160.0, Text { text: album.title } }
        }
    }
}

Spacer

빈칸입니다. 자기 프로퍼티는 하나도 없고, 크기는 모든 위젯이 가진 Modifier 속성에서 옵니다. Compose의 Spacer도 그렇게 크기를 정합니다. Spacer { weight: 1.0 }은 뒤따르는 것을 Row의 끝으로 밀어냅니다.

텍스트와 입력

Text

type_role 하나가 디자인 시스템의 크기, 굵기, 행간, 자간을 한꺼번에 가져옵니다. 각 오버라이드는 정확히 한 축만 바꾸고 프로퍼티 갱신 하나만큼의 비용이 드므로, 글자 크기를 바꾸어도 나머지 스타일은 다시 보내지 않습니다.

프로퍼티타입기본값효과
textString(변환되는 것이면 무엇이든)필수글자 자체.
type_roleTypeRole없음타입 사다리의 단.
font_sizef32 sp없음그 단의 크기를 덮어씁니다.
font_weightu16없음그 단의 굵기를 덮어씁니다. 100에서 900.
line_heightf32없음그 단의 행간을 덮어씁니다.
letter_spacingf32없음그 단의 자간을 덮어씁니다.
colorPaint없음잉크. 보통은 역할로.
text_alignTextAlign없음텍스트 자신의 상자 안에서의 정렬.
max_linesu32없음이 줄 수를 넘으면 자릅니다.
overflowTextOverflow없음자를 때의 모양.
rust
rsx! {
    Text {
        text: entry.summary,
        type_role: TypeRole::Body,
        color: Paint::Role(ColorRole::OnSurfaceVariant),
        max_lines: 2,
        overflow: TextOverflow::Ellipsis,
    }
}

디자인 시스템이 정하는 것: 타입 사다리 전체, 그리고 밝을 때와 어두울 때 OnSurfaceVariant가 어떤 색인지.

TextField

TextField는 비제어이고 value 프로퍼티가 없습니다. 편집 버퍼, 선택 영역, IME 조합 상태는 렌더러에 있고, 러스트는 이벤트로 변화를 알게 됩니다. 이것은 단순화가 아니라 이 프로젝트의 핵심 결정입니다. 작성 중인 글자를 왕복시키면 조립 중인 한글 음절이 초기화되기 때문입니다. 자세한 이야기는 rsx! 의 위젯에 있습니다.

프로퍼티타입기본값효과
placeholderString""비어 있을 때 보여 줍니다.
enabledbooltrue입력을 받을지.
multilineboolfalseEnter가 줄바꿈이 될 수 있는지.
type_roleTypeRole없음필드 자신의 글자가 놓이는 단. 경로나 코드 조각이면 Mono.
on_value_changeEventHandler<String>동작 없음현재 글자를 알려 줍니다.
on_submitEventHandler<String>동작 없음확정된 값.
on_focus_lostEventHandler<()>동작 없음포커스를 잃었습니다.
on_key_downEventHandler<KeyEvent>동작 없음키 하나. 처리했다고 말하려면 consume().
rust
rsx! {
    TextField {
        placeholder: "메시지를 쓰세요",
        multiline: true,
        fill_max_width: true,
        on_value_change: move |value| draft.set(value),
        on_key_down: move |event: KeyEvent| {
            if event.key() == Key::Enter && !event.shift_key() {
                send();
                event.consume();
            }
        },
    }
}

KeyEvent는 key(), shift_key(), ctrl_key(), alt_key(), meta_key(), consume(), consumed()를 내놓습니다. Key enum에는 오늘 Key::Enter 하나가 있습니다. IME가 조합 중일 때는 키 이벤트가 러스트로 아예 오지 않으며, 위 조건이 그저 편한 것이 아니라 옳은 이유가 그것입니다.

SelectionContainer

텍스트는 기본적으로 고를 수 없습니다. 그래서 버튼 라벨이나 탐색 항목이 문서처럼 끌려서 골라지는 일이 없습니다. 읽는 글 한 덩어리를 SelectionContainer로 감싸면 그 안의 모든 Text가, 얼마나 깊이 있든 몇 개든, 하나의 선택 영역이 됩니다. 문단, 목록 항목, 가로로 스크롤되는 코드 한 줄을 한 번에 끌어서 고르고 플랫폼의 단축키나 우클릭 메뉴로 복사할 수 있고, 블록마다 줄을 바꿔 들어갑니다. 안에 있는 버튼 같은 컨트롤은 선택에 들어가지 않고 그대로 눌립니다. Modifier 속성 말고는 받는 것이 없고, 고르는 일과 복사하는 일은 Rust로 아무것도 보내지 않습니다. 이벤트도 없고 선택 범위를 읽는 길도 없습니다. 스크롤 위치나 포커스처럼 렌더러의 상태이기 때문입니다.

컨트롤

Button

variant는 디자인 시스템의 컴포넌트 규칙이 붙는 이음매입니다. 같은 rsx!가 Material의 채운 버튼으로도, Cupertino의 캡슐로도, Fluent의 강조 버튼으로도 그려지며, 그것은 새는 것이 아니라 옳은 동작입니다.

프로퍼티타입기본값효과
textString필수레이블.
enabledbooltrue누를 수 있는지.
variantButtonVariant없음Filled, Tonal, Outlined, Text.
colorPaint없음레이블의 색. 의미가 variant의 것이 아닌 드문 버튼을 위해. 파괴적인 동작이 그 경우입니다. 평범한 버튼인데 레이블이 ColorRole::Error인 것.
iconOption<IconRole>없음버튼 위의 글리프를 그림이 아니라 뜻으로 지정합니다. text가 비어 있으면 그 아이콘이 버튼의 접근성 이름이기도 하므로, 막대에 놓인 아이콘 버튼에 이름이 없는 일이 생기지 않습니다.
backgroundPaint없음채움. 지정하면 variant의 채움을 대신하고, 모양과 테두리와 높이와 안쪽 여백은 디자인 시스템의 것으로 남습니다.
on_clickEventHandler<()>동작 없음눌렸습니다.
rust
rsx! {
    Button {
        text: "삭제",
        variant: ButtonVariant::Text,
        color: Paint::Role(ColorRole::Error),
        on_click: move |_| remove(id),
    }
}

디자인 시스템이 정하는 것: 모양, 채움, 여백, 눌림 피드백, 그리고 Tonal 버튼이 정말 톤을 쓰는지까지.

Checkbox, RadioButton, Switch

셋 다 제어형입니다. 프로퍼티가 말하는 그대로 그리고 사용자가 무엇을 원했는지 보고하므로, 호스트가 쥔 값과 화면의 컨트롤이 어긋날 수 없습니다. 라디오 버튼이 어떤 형제를 배제하는지는 애플리케이션의 일이므로, 위젯은 자기가 선택된 것인지만 들고 있습니다.

위젯상태 프로퍼티기본값그 밖에
Checkboxchecked: boolfalseenabled: bool = true, on_change: EventHandler<bool>
RadioButtonselected: boolfalse같은 둘
Switchchecked: boolfalse같은 둘
rust
rsx! {
    Row {
        alignment: Alignment::CenterStart,
        space_role: SpaceRole::Sm,
        Checkbox {
            checked: task.done,
            on_change: move |done| set_done(task.id, done),
        }
        Text { text: task.title }
    }
}

Slider

드래그가 지나가는 위치는 렌더러에 남으므로 손가락을 따라가는 데에는 경계 호출이 들지 않습니다. value는 그 위치의 출발점이고, 변화가 다른 데서 왔을 때 그것을 옮깁니다.

프로퍼티타입기본값효과
valuef320.0손잡이의 자리.
minf320.0아래쪽 끝.
maxf321.0위쪽 끝.
stepsu320양 끝 사이의 멈춤점. 0이면 연속입니다.
enabledbooltrue끌 수 있는지.
on_changeEventHandler<f32>동작 없음사용자가 멈춘 값.

Dropdown

목록에서 하나를 고르는 것입니다. 자식 하나가 선택지 하나이고, 렌더러가 사용자가 멈춘 위치를 보고합니다. 팝업이 열렸는지는 렌더러의 상태입니다.

프로퍼티타입기본값효과
selected_indexusize0어느 자식이 현재의 선택인지.
enabledbooltrue열리는지.
on_changeEventHandler<usize>동작 없음고른 인덱스.
childrenElement필수선택지마다 자식 하나.

DatePicker와 TimePicker

날짜는 1970-01-01부터의 날 수를 i64로, 시각은 자정부터의 분을 u32로 셉니다. 둘 다 value(필수), 같은 타입의 선택적 min과 max, 기본값이 true인 enabled, 그리고 value와 같은 타입을 실어 나르는 on_change를 받습니다.

달력 격자나 휠이나 플라이아웃을 요청하는 프로퍼티는 없고, 포맷 문자열도 없습니다. 시간대, 로케일, 한 주의 첫 요일, 표시 형식은 렌더러에 남습니다. 플랫폼의 설정을 읽을 수 있는 쪽이 렌더러뿐이기 때문입니다. 포맷 문자열이 경계를 넘는 순간 "플랫폼을 따른다"는 말은 렌더러가 지킬 수 없는 주장이 됩니다.

rust
rsx! {
    DatePicker {
        value: due(),
        min: today,
        on_change: move |day| due.set(day),
    }
    TimePicker {
        value: remind_at(),
        on_change: move |minutes| remind_at.set(minutes),
    }
}

ProgressIndicator

value(f32, 기본값 0.0)는 얼마나 진행했는지, determinate(bool, 기본값 true)는 value가 의미를 갖는지, circular(bool, 기본값 false)는 지원하는 모든 디자인 시스템이 가진 두 형태 중 하나를 고릅니다. 속도도, 이징도, 트랙 색도 없습니다. 비확정 표시기가 어떻게 움직이는지는 모션이고, 모션은 디자인 시스템의 규칙입니다.

컨테이너와 구분선

Card, Surface, TopAppBar

자기 겉모습을 가지지 않는 컨테이너 셋입니다. 각각 열세 개의 Modifier 속성과 자식을 받고, 그 밖에는 아무것도 받지 않습니다.

rust
rsx! {
    TopAppBar {
        fill_max_width: true,
        Text { text: "드럼 학원", type_role: TypeRole::Title, weight: 1.0 }
        Button { text: "추가", variant: ButtonVariant::Text, on_click: move |_| add() }
    }
    Card {
        fill_max_width: true,
        padding_role: SpaceRole::Md,
        Text { text: "네 마디, 손만" }
    }
}

Divider와 Separator

Divider는 두 가지 사이의 선입니다. vertical(bool, 기본값 false)이 애플리케이션이 하는 유일한 결정이고, 두께와 색과 들여쓰기는 모두 디자인 시스템에서 옵니다.

Separator는 그룹 목록 코드가 이미 쓰는 이름으로 부르는 같은 선이며, Modifier 속성을 받지 않는 유일한 위젯입니다. 프로퍼티는 color: Option<Paint> 하나이고 기본값은 없음입니다. 조용한 가장자리가 아닌 다른 것으로 줄을 긋는 드문 목록을 위한 것입니다.

Tooltip

자식에 붙는 설명입니다. text(String, 필수)와 자식을 받습니다. 호버 팝업이기도 하지만 그만큼 설명이기도 한데, 렌더러가 그 글자를 접근성 트리에도 내보내기 때문입니다. 포인터만이 그곳에 닿는 길은 아닙니다.

Badge

개수, 새것이 있다는 표시, 짧은 글자 하나를 싣는 작은 표시입니다. value (Option<u32>)는 개수, text(Option<String>)는 짧은 글자이고, 둘 다 없으면 점입니다. color(Option<ColorRole>)는 채울 역할이며 기본은 오류 역할입니다. 리터럴 색은 받지 않습니다. 자식이 하나 있으면 그 자식(아이콘, 탐색 항목)에 붙고, 없으면 혼자 섭니다(목록 행 끝의 개수). 붙는 자리, 모양, 120을 그대로 쓸지 99+로 줄일지는 디자인 시스템이 정하므로, 같은 선언이 어떤 시스템에서는 모서리에, 다른 시스템에서는 줄 끝에 놓입니다. 배지의 내용은 붙은 자식의 설명에 덧붙어 읽히고 따로 읽히지 않습니다.

윈도잉 목록

LazyColumn과 LazyRow는 한 프로토콜의 두 축입니다. 항목이 몇 개인지와 하나를 어떻게 만드는지를 선언하면, 렌더러가 자기가 볼 수 있는 범위를 요청하고, 컴포넌트는 정확히 그 범위만 만들어 냅니다.

프로퍼티타입기본값효과
item_countusize필수전체 항목 수.
itemCallback<usize, Element>필수인덱스의 항목을 만듭니다.
key_ofOption<Callback<usize, String>>없음항목마다의 안정된 정체성. 없으면 인덱스를 쓰는데, 목록이 재정렬되거나 걸러지는 순간 그것은 틀린 답입니다.
rust
rsx! {
    LazyColumn {
        fill_max_width: true,
        fill_max_height: true,
        item_count: tasks.read().len(),
        key_of: move |index: usize| tasks.read()[index].id.to_string(),
        item: move |index: usize| {
            let task = tasks.read()[index].clone();
            rsx! { Text { text: task.title } }
        },
    }
}

buffer 프로퍼티도, 스크롤 오프셋도 없습니다. 미리 읽는 버퍼는 렌더러의 것입니다. 스크롤 위치를 가진 쪽이 렌더러이고, 얼마나 앞서 요청해야 하는지 아는 쪽도 렌더러뿐이기 때문입니다. 러스트에서 범위를 넓히면 렌더러의 배치가 깨집니다. 렌더러가 받는 첫 인덱스가 item_count개짜리 진짜 Compose 지연 목록을 그릴 수 있게 하는 것이기 때문입니다. 데이터는 호스트에 남으므로 되돌아 스크롤하면 똑같은 하위 트리가 다시 만들어집니다. 전체 이야기는 목록과 스트리밍에 있습니다.

Navigation과 NavigationItem

목적지들과 그중 선택된 하나, 그리고 그것이 이끄는 화면입니다. NavigationItem인 자식이 목적지이고, 나머지 자식은 선택된 화면의 내용입니다. 인덱스가 아니라 종류로 가르므로, 어떤 조건에서만 존재하는 목적지가 있어도 두 목록을 손으로 맞춰 둘 필요가 없습니다.

여기에는 바인지 레일인지 드로어인지를 말하는 것이 없습니다. 렌더러는 이미 창을 재 보았으므로 스스로 고릅니다. 휴대폰 모양의 창에서는 아래를 가로지르는 바, 태블릿 모양이면 옆의 레일, 데스크톱이면 열려 있는 드로어입니다. 그중 하나를 요청할 수 있는 프로퍼티는 없습니다.

위젯프로퍼티타입기본값
Navigationselected_indexusize, 자식이 아니라 목적지를 셉니다0
childrenElement필수
NavigationItemtextString""
iconOption<IconRole>없음
colorOption<Paint>없음
enabledbooltrue
on_clickEventHandler<()>동작 없음

목적지의 레이블과 아이콘이 자식 트리가 아니라 프로퍼티인 이유는, 자식 트리가 배치를 고정해 버리기 때문입니다. 세 가지 표현이 존재하는 이유가 바로 그 배치가 다르다는 것입니다. 바는 레이블을 아이콘 아래에 쌓고, 드로어는 옆에 놓습니다.

color를 비워 두면 선택된 모습을 디자인 시스템이 정합니다. 애플리케이션이 플랫폼을 따라갈 때 원하는 동작입니다. 지정하면 그 색이 선택된 상태와 아닌 상태 모두에 쓰입니다. 양쪽 모두인 것은 의도입니다. 자기 색을 말하는 목적지는 자기 절반이 아니라 자기 자신에 대해 말하는 것이므로, 두 상태를 다르게 두고 싶은 화면은 목적지마다 색을 따로 줍니다. 이 속성이 필요한 경우는 막대에 강조색이 아예 없는 디자인입니다. 그때 활성 시스템에 물으면 없어야 할 강조색이 생깁니다.

rust, compose-rust/samples/academic 이 쓰는 모양
rsx! {
    Navigation {
        fill_max_width: true,
        fill_max_height: true,
        selected_index: destination().index(),
        for choice in Destination::STRIP {
            NavigationItem {
                key: "{choice.label()}",
                text: choice.label(),
                icon: choice.icon(),
                on_click: move |()| destination.set(choice),
            }
        }
        Column { fill_max_width: true, {page} }
    }
}

selected_index는 렌더러의 선택에 출발점을 주고, 변화가 탭이 아닌 다른 데서 왔을 때 그것을 옮깁니다. 탭은 렌더러 안에서 선택을 옮기고 그 목적지 자신의 on_click을 발생시켜 보고하므로, 화면을 바꾸는 데에는 이벤트 하나가 들고 목적지 목록을 다시 만들지는 않습니다.

Tabs

세그먼트가 늘어선 줄이고, 자식 하나가 탭 하나입니다. selected_index(usize, 기본값 0)는 Navigation의 것과 똑같이 동작합니다. 렌더러의 선택에 출발점을 주고, 탭을 누르면 그 탭 자신의 on_click이 발생할 뿐 줄 전체를 다시 만들지 않습니다.

오버레이와 메시지

Dialog, Menu, Sheet는 하나의 계약을 공유합니다. 열림 상태는 렌더러의 것입니다. 넘기는 불리언은 그 상태의 출발점이고, 렌더러가 아닌 다른 데서 온 변화를 실어 나릅니다. 들어오고 나가는 동작은 렌더러가 직접 돌리므로 애니메이션이 러스트를 왕복하지 않습니다. 닫기 핸들러는 사용자가 닫아 달라고 할 때 한 번 발생하며, 불리언을 false로 바꿔 그 요청을 들어줄지는 애플리케이션이 정합니다.

위젯열림 프로퍼티그 밖에
Dialogopen: bool = falseon_dismiss: EventHandler<()>, 자식
Menuexpanded: bool = falseon_dismiss, anchor: Element(필수), 자식
Sheetopen: bool = falseon_dismiss, 자식

Menu의 anchor는 팝업이 매달리는 위젯이고, 첫 번째 자식으로 건너가므로 러스트가 화면 좌표를 하나도 모르는 채로 렌더러가 팝업을 놓을 수 있습니다. 항목마다 자기 on_click을 주고, 그것이 어느 것이 골라졌는지 알려 줍니다. 팝업을 닫는 것은 렌더러가 스스로 합니다.

rust, compose-rust/samples/todo 가 쓰는 모양
rsx! {
    Menu {
        expanded: menu_open() == Some(task.id),
        on_dismiss: move |_| menu_open.set(None),
        anchor: rsx! {
            Button {
                text: "\u{22ef}",
                variant: ButtonVariant::Text,
                on_click: move |_| menu_open.set(Some(task.id)),
            }
        },
        Button { text: "편집", variant: ButtonVariant::Text, on_click: move |_| edit(task.id) }
    }
}

Sheet가 어느 가장자리에서 들어오는지는 렌더러가 재 본 너비를 보고 정합니다. 좁은 창에서는 아래에서 위로, 넓은 창에서는 옆에서 안으로 들어옵니다. 시트를 끌고 있는 동안 러스트는 아무것도 듣지 못하고, 닫힘으로 끝난 드래그만 한 번 보고됩니다.

지연 목록 안의 Menu는 항목을 조건 없이 선언합니다

Menu의 자식을 열려 있을 때만 만들면 더 싸고, 끝나지 않습니다. LazyColumn 항목 안에서 자식이 조건문이나 반복문인 Menu는 스택이 넘칠 때까지 재귀합니다. 열려 있든 아니든 항목을 선언하면 보이는 행마다 앵커 하나와 항목 몇 개의 비용이 드는데, 그것은 목록이 아니라 창의 크기를 따라갑니다. compose-rust/samples/todo가 그렇게 하고 있고 주석으로도 적어 두었습니다.

한 번 지나가는 메시지

메시지는 트리의 일부가 아닙니다. 자리가 아니라 수명을 가지고, 그 수명은 렌더러의 것입니다. 얼마나 머무를지, 어디에 앉을지, 첫 번째가 아직 떠 있는데 두 번째가 오면 어떻게 할지 모두 그렇습니다. 그래서 메시지는 띄워 보내고 잊는 것이며, 지금 호출이 만들어 내는 배치에 실려 나갑니다.

rust
show_message("저장했습니다");

Message::new("할 일을 지웠습니다")
    .with_action("실행 취소", move |()| restore())
    .with_duration(MessageDuration::Long)
    .show();

메시지가 어디에 나타나는지, 어떻게 생겼는지를 말하는 것은 여기에 없습니다. Material은 스낵바라 부르고, Apple은 한 번 지나가는 오버레이, GNOME은 토스트라 부릅니다. Dialog의 생김새를 정하는 것과 같은 방식으로 디자인 시스템이 정합니다. 길이는 MessageDuration::Short 또는 Long이고, 그것이 몇 밀리초인지도 디자인 시스템의 답입니다. 무기한이 없는 것은 일부러입니다. 확인을 받아야 하는 메시지는 Dialog입니다.

그림과 드로잉

Image와 Icon

둘 다 asset_id: u32를 받고, 필수입니다. 바이트는 Host::register_asset으로 렌더러에 한 번 건너갔고, 프레임마다 건너가는 것은 id입니다. Icon은 color: Option<Paint> 틴트도 받습니다.

Host::register_icon은 그림이 아니라 의미를 등록하므로, 그렇게 등록한 id는 활성 디자인 시스템이 그 IconRole에 대해 그리는 그림으로 나옵니다.

Canvas

위젯 어휘로는 만들 수 없는 픽셀입니다. 차트, 스파크라인, 서명판, 링. commands: DrawList가 필수이고 유일한 프로퍼티입니다.

목록이 콜백이 아니라 값인 이유는, 미리 컴파일된 렌더러 안으로 그리기 코드가 경계를 넘어갈 수 없기 때문입니다. 같은 목록 둘은 속성 비교 한 번이므로, 같은 차트를 다시 그리는 프레임은 아무것도 보내지 않습니다. 크기는 Modifier 속성에서 오고, 좌표는 위젯 왼쪽 위 모서리에서 잰 dp입니다. 색은 Paint를 지나므로, 역할로 칠한 차트는 다른 모든 것처럼 디자인 시스템을 따릅니다.

rust
let commands = DrawList::builder()
    // paint, x, y, width, height, radius, stroke_width. 선 두께가 0이면 채웁니다.
    .round_rect(Paint::Role(ColorRole::SurfaceContainer), 0.0, 0.0, 220.0, 96.0, 12.0, 0.0)
    // paint, 중심 x, 중심 y, 반지름, stroke_width
    .circle(Paint::Role(ColorRole::Primary), 48.0, 48.0, 24.0, 2.0)
    .build();

rsx! {
    Canvas { width: 220.0, height: 96.0, commands }
}

DrawListBuilder에는 line, rect, round_rect, circle, arc, polyline_ref, text_at이 있습니다. 긴 목록을 쓰기 전에 dioxus-compose/src/drawing.rs에서 시그니처를 확인하세요. 인자는 위치로 구분되고, 채움인지 선인지는 따로 둔 플래그나 다른 메서드가 아니라 끝에 오는 선 두께입니다.

창 크기

UI는 러스트에서 쓰지만 재는 것은 렌더러이므로, 컴포넌트가 창이 얼마나 넓은지 알 수 있는 길은 렌더러가 알려 주는 것뿐입니다. use_window_size()가 그 통로입니다.

rust
let window = use_window_size();

rsx! {
    if window.is_expanded() {
        Row { Sidebar {} Content {} }
    } else {
        Content {}
    }
}
클래스너비모양
WindowSizeClass::Compact600 dp 미만휴대폰 모양. 한 단.
WindowSizeClass::Medium600 dp 이상 840 dp 미만태블릿 모양.
WindowSizeClass::Expanded840 dp 이상데스크톱 모양. 상시 사이드바를 둘 자리가 있습니다.

훅은 width_dp, height_dp, class를 가진 WindowSize와 is_compact(), is_medium(), is_expanded()를 돌려줍니다. 컴포넌트 밖에서는 window_size()가 같은 값입니다.

보고되는 것은 클래스를 넘는 순간뿐입니다. 렌더러는 클래스가 바뀔 때 이벤트 하나를 보내고 그 사이에는 아무것도 보내지 않으므로, 창을 700 dp에서 800 dp로 끌어도 다시 그려지는 것은 하나도 없고, 실제로 바뀔 때에도 훅을 부른 컴포넌트만 깨어납니다. 수치는 클래스가 마지막으로 바뀐 순간에 잰 값이지 드래그의 모든 픽셀을 따라가는 값이 아닙니다. 레이아웃 패스마다 바뀌는 크기는 레이아웃 패스마다 VirtualDom을 돌리게 됩니다.

렌더러가 아직 아무것도 재지 않았을 때 훅은 0 곱하기 0의 Compact를 보고합니다. 의도된 것입니다. 한 단짜리 레이아웃은 어떤 너비에서도 쓸 만하므로, 첫 측정이 오기 전의 단 한 번의 렌더는 망가지지 않고 다만 필요보다 좁을 뿐입니다.

훅은 마지막에 꺼내세요

Navigation과 Sheet는 부탁하지 않아도 이미 적응하고, 그것도 러스트에서 트리를 다시 만드는 대신 렌더러가 이미 가진 노드를 옮기는 방식으로 합니다. use_window_size()는 위젯 집합이 대신 내려 줄 수 없는 레이아웃 결정에 쓰세요. 한 단 대신 두 단, 늘 열려 있는 사이드바, 단 수가 바뀌는 격자 같은 것입니다.

각 위젯의 상태는 어디에 있나

큰 규칙은 애플리케이션 상태는 애플리케이션의 것이고 UI에 국한된 상태는 렌더러의 것이라는 것입니다. 아래는 사람들이 놀라는 자리의 목록입니다. 제어형처럼 보이는데 아니거나, 비제어처럼 보이는데 제어형인 것들입니다.

위젯렌더러가 가진 것애플리케이션이 넘기는 것
TextField편집 버퍼 전체, 선택 영역, IME 조합값은 아예 없습니다. 변화는 이벤트로 알게 됩니다.
Dialog, Sheet, Menu열림 상태, 그리고 들어오고 나가는 애니메이션open 또는 expanded가 출발점이자 바깥의 변화를 싣습니다.
Dropdown팝업이 열렸는지selected_index만.
Navigation, Tabs어느 것에 표시가 붙었는지, 그 사이의 이동selected_index가 출발점. 탭은 그 자식의 on_click을 발생시킵니다.
Slider드래그가 지나가는 위치value가 출발점이자 바깥에서 옮기는 수단.
ScrollColumn, ScrollRow, LazyColumn, LazyRow스크롤 위치, 미리 읽는 버퍼item_count, item, key_of.
Checkbox, RadioButton, Switch없습니다. 완전한 제어형입니다checked나 selected가 그려지는 그대로입니다.
DatePicker, TimePicker고르는 방식, 로케일, 시간대, 표시 형식value, min, max.
모든 위젯포커스, 호버, 눌림 피드백, 애니메이션 진행없습니다.

확장

확장은 러스트 하나와 코틀린 하나로 이루어진 컴파일 시점의 소스 쌍이고, 양쪽에 함께 빌드됩니다. 런타임 플러그인이 아닙니다. 배포된 렌더러에는 새 위젯을 불러올 방법이 없습니다. 크레이트에는 예제가 하나 들어 있습니다. LinearProgressIndicator는 progress: f32 프로퍼티 하나를 받고 다른 위젯처럼 prelude에서 나옵니다.

직접 만들려면 dioxus-compose/src/extensions.rs의 레지스트리를 고친 사본을 두고, 위젯과 프로퍼티 태그를 덧붙이고, 짝이 되는 코틀린 렌더러에서 새 종류를 그려야 합니다. 확장 위젯 태그는 100부터 시작하므로 나중에 추가되는 코어 위젯과 절대 부딪히지 않습니다.