레퍼런스
컴포저블 레퍼런스
애플리케이션이 쓸 수 있는 모든 컴포저블과, 그 프로퍼티, 기본값, 그리고 디자인 시스템에
맡기는 부분입니다. 목록은 닫혀 있습니다. dioxus-compose의 prelude가 내보내는
것이 전부이고, 렌더러는 그 밖의 어떤 것도 해석하지 않습니다. 알지 못하는 위젯 종류는
프로세스를 죽이는 대신 ProtocolError 이벤트가 됩니다.
prelude가 주는 것
import 한 줄이면 위젯, 역할, 훅, 시그널, 매크로가 모두 들어옵니다.
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! 안에서 경로를 붙여 쓰면 됩니다.
rsx! {
dioxus_compose::Box {
fill_max_width: true,
alignment: Alignment::Center,
Text { text: "가운데" }
}
}
Modifier 속성
Compose에서 Modifier가 어떤 컴포저블에나 붙듯이 여기서도 그렇습니다.
이 페이지의 모든 위젯이 아래 열세 가지를 전부 받습니다.
Text와 Spacer도 마찬가지이므로, 위젯마다 다시 적지 않습니다.
예외는 Separator 하나뿐인데, 프로퍼티가 하나인 단축 표현이라
color만 받습니다.
| 속성 | 타입 | 기본값 | 효과 |
|---|---|---|---|
weight | f32 | 없음 | Row나 Column 안에서 남은 주축 공간의 몫. |
fill_max_width | bool | false | 가능한 너비를 모두 차지합니다. |
fill_max_height | bool | false | 가능한 높이를 모두 차지합니다. |
width | f32 dp | 없음 | 고정 너비. |
height | f32 dp | 없음 | 고정 높이. |
padding | f32 dp | 없음 | 네 변에 같은 여백을, 직접 고른 dp로. |
padding_role | SpaceRole | 없음 | 같은 여백을, 디자인 시스템이 고른 dp로. 이쪽이 낫습니다. |
background | Paint | 없음 | 위젯 뒤의 채움. |
shape_role | ShapeRole | 없음 | 반지름을 디자인 시스템이 정하는 모서리. |
corner_radius | f32 dp | 없음 | 직접 고른 dp의 모서리 반지름. |
border_width | f32 dp | 없음 | 테두리의 절반. border_color까지 있어야 그려집니다. |
border_color | Paint | 없음 | 나머지 절반. |
elevation | f32 dp | 없음 | 디자인 시스템이 정했을 기본 높이를 덮어씁니다. |
설정하지 않음은 0이 아닙니다. 쓰지 않은 Modifier는 0이 아니라 아무것도
보내지 않습니다. 여백이 없는 것과 여백이 0인 것은 화면에서 같아 보이지만, 그중 하나만
뮤테이션 비용을 치러야 하기 때문입니다. 슬롯을 공유하는 쌍이 셋 있어서 둘 다 의미 있게
설정할 수는 없습니다. padding과 padding_role,
shape_role과 corner_radius, 그리고 border_width와
border_color입니다. 마지막 쌍은 하나의 Modifier이고 양쪽이 모두 필요합니다.
와이어 포맷에는 Clickable Modifier가 있고 렌더러도 그것을 Compose 체인에
넣지만, 타입이 붙은 위젯 중에 이것을 프로퍼티로 노출하는 것은 없습니다. 지금 클릭은
Button이나 NavigationItem에서 나옵니다.
Clickable에 닿으려면 소문자 엘리먼트 형태
(card { onclickable: move |_| ... })를 써야 하는데, 이 저장소의 어떤
샘플도 그렇게 하지 않으므로 위젯 API의 일부라기보다 검증되지 않은 것으로 보시기 바랍니다.
역할 어휘
위젯은 리터럴이 아니라 역할을 내보냅니다. 역할은 일의 이름("강조색",
"제목 단", "중간 모서리")이고, 렌더러가 활성 디자인 시스템의 토큰
표에 대고 그것을 해석합니다. 하나의 rsx! 트리가 이 기계에서는 Material 3로,
저 기계에서는 Cupertino로 나오는 이유가 이것입니다. 표가 어떻게 정해지는지는
디자인 시스템에 있습니다.
Paint와 Color
색을 받는 자리의 타입은 언제나 Paint이고, 역할이거나 리터럴 ARGB 값입니다.
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 | 변종 | 쓰이는 곳 |
|---|---|---|
Arrangement | Start, Center, End, SpaceBetween, SpaceAround, SpaceEvenly | Row와 Column의 주축 |
Alignment | TopStart, TopCenter, TopEnd, CenterStart, Center, CenterEnd, BottomStart, BottomCenter, BottomEnd | Row와 Column의 교차축, Box의 양축 |
TextAlign | Start, Center, End, Justify | Text |
TextOverflow | Clip, Ellipsis, Visible | max_lines와 함께 Text |
ButtonVariant | Filled, Tonal, Outlined, Text | Button |
MessageDuration | Short, Long | Message |
WindowSizeClass | Compact, Medium, Expanded | use_window_size |
레이아웃
Column과 Row
세로로 쌓는 것과 가로로 쌓는 것입니다. 주축의 배치는 arrangement, 자식 사이의
간격은 spacing 또는 space_role, 교차축은
alignment가 정합니다.
| 프로퍼티 | 타입 | 기본값 | 효과 |
|---|---|---|---|
arrangement | Arrangement | 없음 | 주축을 따라 자식을 어떻게 분배할지. |
spacing | f32 dp | 없음 | 자식 사이의 간격을 직접 고른 dp로. |
space_role | SpaceRole | 없음 | 같은 간격을 디자인 시스템이 고른 dp로. |
alignment | Alignment | 없음 | 자식의 교차축 정렬. |
| children | Element | 필수 | 쌓이는 내용, 순서대로. |
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을 쓰세요.
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를 쓰세요.
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 하나가 디자인 시스템의 크기, 굵기, 행간, 자간을 한꺼번에
가져옵니다. 각 오버라이드는 정확히 한 축만 바꾸고 프로퍼티 갱신 하나만큼의 비용이 드므로,
글자 크기를 바꾸어도 나머지 스타일은 다시 보내지 않습니다.
| 프로퍼티 | 타입 | 기본값 | 효과 |
|---|---|---|---|
text | String(변환되는 것이면 무엇이든) | 필수 | 글자 자체. |
type_role | TypeRole | 없음 | 타입 사다리의 단. |
font_size | f32 sp | 없음 | 그 단의 크기를 덮어씁니다. |
font_weight | u16 | 없음 | 그 단의 굵기를 덮어씁니다. 100에서 900. |
line_height | f32 | 없음 | 그 단의 행간을 덮어씁니다. |
letter_spacing | f32 | 없음 | 그 단의 자간을 덮어씁니다. |
color | Paint | 없음 | 잉크. 보통은 역할로. |
text_align | TextAlign | 없음 | 텍스트 자신의 상자 안에서의 정렬. |
max_lines | u32 | 없음 | 이 줄 수를 넘으면 자릅니다. |
overflow | TextOverflow | 없음 | 자를 때의 모양. |
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! 의 위젯에 있습니다.
| 프로퍼티 | 타입 | 기본값 | 효과 |
|---|---|---|---|
placeholder | String | "" | 비어 있을 때 보여 줍니다. |
enabled | bool | true | 입력을 받을지. |
multiline | bool | false | Enter가 줄바꿈이 될 수 있는지. |
type_role | TypeRole | 없음 | 필드 자신의 글자가 놓이는 단. 경로나 코드 조각이면 Mono. |
on_value_change | EventHandler<String> | 동작 없음 | 현재 글자를 알려 줍니다. |
on_submit | EventHandler<String> | 동작 없음 | 확정된 값. |
on_focus_lost | EventHandler<()> | 동작 없음 | 포커스를 잃었습니다. |
on_key_down | EventHandler<KeyEvent> | 동작 없음 | 키 하나. 처리했다고 말하려면 consume(). |
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의 강조 버튼으로도 그려지며,
그것은 새는 것이 아니라 옳은 동작입니다.
| 프로퍼티 | 타입 | 기본값 | 효과 |
|---|---|---|---|
text | String | 필수 | 레이블. |
enabled | bool | true | 누를 수 있는지. |
variant | ButtonVariant | 없음 | Filled, Tonal, Outlined, Text. |
color | Paint | 없음 | 레이블의 색. 의미가 variant의 것이 아닌 드문 버튼을 위해. 파괴적인 동작이 그 경우입니다. 평범한 버튼인데 레이블이 ColorRole::Error인 것. |
icon | Option<IconRole> | 없음 | 버튼 위의 글리프를 그림이 아니라 뜻으로 지정합니다. text가 비어 있으면 그 아이콘이 버튼의 접근성 이름이기도 하므로, 막대에 놓인 아이콘 버튼에 이름이 없는 일이 생기지 않습니다. |
background | Paint | 없음 | 채움. 지정하면 variant의 채움을 대신하고, 모양과 테두리와 높이와 안쪽 여백은 디자인 시스템의 것으로 남습니다. |
on_click | EventHandler<()> | 동작 없음 | 눌렸습니다. |
rsx! {
Button {
text: "삭제",
variant: ButtonVariant::Text,
color: Paint::Role(ColorRole::Error),
on_click: move |_| remove(id),
}
}
디자인 시스템이 정하는 것: 모양, 채움, 여백, 눌림 피드백, 그리고 Tonal
버튼이 정말 톤을 쓰는지까지.
Checkbox, RadioButton, Switch
셋 다 제어형입니다. 프로퍼티가 말하는 그대로 그리고 사용자가 무엇을 원했는지 보고하므로, 호스트가 쥔 값과 화면의 컨트롤이 어긋날 수 없습니다. 라디오 버튼이 어떤 형제를 배제하는지는 애플리케이션의 일이므로, 위젯은 자기가 선택된 것인지만 들고 있습니다.
| 위젯 | 상태 프로퍼티 | 기본값 | 그 밖에 |
|---|---|---|---|
Checkbox | checked: bool | false | enabled: bool = true, on_change: EventHandler<bool> |
RadioButton | selected: bool | false | 같은 둘 |
Switch | checked: bool | false | 같은 둘 |
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는 그 위치의 출발점이고, 변화가 다른 데서 왔을 때 그것을
옮깁니다.
| 프로퍼티 | 타입 | 기본값 | 효과 |
|---|---|---|---|
value | f32 | 0.0 | 손잡이의 자리. |
min | f32 | 0.0 | 아래쪽 끝. |
max | f32 | 1.0 | 위쪽 끝. |
steps | u32 | 0 | 양 끝 사이의 멈춤점. 0이면 연속입니다. |
enabled | bool | true | 끌 수 있는지. |
on_change | EventHandler<f32> | 동작 없음 | 사용자가 멈춘 값. |
Dropdown
목록에서 하나를 고르는 것입니다. 자식 하나가 선택지 하나이고, 렌더러가 사용자가 멈춘 위치를 보고합니다. 팝업이 열렸는지는 렌더러의 상태입니다.
| 프로퍼티 | 타입 | 기본값 | 효과 |
|---|---|---|---|
selected_index | usize | 0 | 어느 자식이 현재의 선택인지. |
enabled | bool | true | 열리는지. |
on_change | EventHandler<usize> | 동작 없음 | 고른 인덱스. |
| children | Element | 필수 | 선택지마다 자식 하나. |
DatePicker와 TimePicker
날짜는 1970-01-01부터의 날 수를 i64로, 시각은 자정부터의 분을
u32로 셉니다. 둘 다 value(필수), 같은 타입의 선택적
min과 max, 기본값이 true인
enabled, 그리고 value와 같은 타입을 실어 나르는
on_change를 받습니다.
달력 격자나 휠이나 플라이아웃을 요청하는 프로퍼티는 없고, 포맷 문자열도 없습니다. 시간대, 로케일, 한 주의 첫 요일, 표시 형식은 렌더러에 남습니다. 플랫폼의 설정을 읽을 수 있는 쪽이 렌더러뿐이기 때문입니다. 포맷 문자열이 경계를 넘는 순간 "플랫폼을 따른다"는 말은 렌더러가 지킬 수 없는 주장이 됩니다.
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 속성과 자식을 받고, 그 밖에는 아무것도 받지 않습니다.
Card는 묶습니다. 카드가 어떻게 생겼는지, 채움과 모서리와 기본 높이는 디자인 시스템의 결정입니다. 기본 높이를 덮어쓸 이유가 있을 때elevation을 주세요.Surface는 배경과 높이만 있는 컨테이너입니다. 카드의 "묶는다"는 뜻이 틀릴 때, 면만 필요할 때 씁니다.TopAppBar는 화면 위를 가로지르는 바이고, 자식이 왼쪽에서 오른쪽으로 놓입니다. 바의 크기와 간격, 아래 내용과 어떻게 갈라지는지는 디자인 시스템의 규칙입니다.
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_count | usize | 필수 | 전체 항목 수. |
item | Callback<usize, Element> | 필수 | 인덱스의 항목을 만듭니다. |
key_of | Option<Callback<usize, String>> | 없음 | 항목마다의 안정된 정체성. 없으면 인덱스를 쓰는데, 목록이 재정렬되거나 걸러지는 순간 그것은 틀린 답입니다. |
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인 자식이 목적지이고, 나머지 자식은 선택된 화면의 내용입니다.
인덱스가 아니라 종류로 가르므로, 어떤 조건에서만 존재하는 목적지가 있어도 두 목록을 손으로
맞춰 둘 필요가 없습니다.
여기에는 바인지 레일인지 드로어인지를 말하는 것이 없습니다. 렌더러는 이미 창을 재 보았으므로 스스로 고릅니다. 휴대폰 모양의 창에서는 아래를 가로지르는 바, 태블릿 모양이면 옆의 레일, 데스크톱이면 열려 있는 드로어입니다. 그중 하나를 요청할 수 있는 프로퍼티는 없습니다.
| 위젯 | 프로퍼티 | 타입 | 기본값 |
|---|---|---|---|
Navigation | selected_index | usize, 자식이 아니라 목적지를 셉니다 | 0 |
| children | Element | 필수 | |
NavigationItem | text | String | "" |
icon | Option<IconRole> | 없음 | |
color | Option<Paint> | 없음 | |
enabled | bool | true | |
on_click | EventHandler<()> | 동작 없음 |
목적지의 레이블과 아이콘이 자식 트리가 아니라 프로퍼티인 이유는, 자식 트리가 배치를 고정해 버리기 때문입니다. 세 가지 표현이 존재하는 이유가 바로 그 배치가 다르다는 것입니다. 바는 레이블을 아이콘 아래에 쌓고, 드로어는 옆에 놓습니다.
color를 비워 두면 선택된 모습을 디자인 시스템이 정합니다.
애플리케이션이 플랫폼을 따라갈 때 원하는 동작입니다. 지정하면 그 색이 선택된 상태와 아닌
상태 모두에 쓰입니다. 양쪽 모두인 것은 의도입니다. 자기 색을 말하는 목적지는 자기 절반이
아니라 자기 자신에 대해 말하는 것이므로, 두 상태를 다르게 두고 싶은 화면은 목적지마다 색을
따로 줍니다. 이 속성이 필요한 경우는 막대에 강조색이 아예 없는 디자인입니다. 그때 활성
시스템에 물으면 없어야 할 강조색이 생깁니다.
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로 바꿔 그 요청을 들어줄지는 애플리케이션이 정합니다.
| 위젯 | 열림 프로퍼티 | 그 밖에 |
|---|---|---|
Dialog | open: bool = false | on_dismiss: EventHandler<()>, 자식 |
Menu | expanded: bool = false | on_dismiss, anchor: Element(필수), 자식 |
Sheet | open: bool = false | on_dismiss, 자식 |
Menu의 anchor는 팝업이 매달리는 위젯이고, 첫 번째 자식으로
건너가므로 러스트가 화면 좌표를 하나도 모르는 채로 렌더러가 팝업을 놓을 수 있습니다.
항목마다 자기 on_click을 주고, 그것이 어느 것이 골라졌는지 알려 줍니다.
팝업을 닫는 것은 렌더러가 스스로 합니다.
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의 자식을 열려 있을 때만 만들면 더 싸고, 끝나지 않습니다.
LazyColumn 항목 안에서 자식이 조건문이나 반복문인 Menu는
스택이 넘칠 때까지 재귀합니다. 열려 있든 아니든 항목을 선언하면 보이는 행마다 앵커
하나와 항목 몇 개의 비용이 드는데, 그것은 목록이 아니라 창의 크기를 따라갑니다.
compose-rust/samples/todo가 그렇게 하고 있고 주석으로도 적어 두었습니다.
한 번 지나가는 메시지
메시지는 트리의 일부가 아닙니다. 자리가 아니라 수명을 가지고, 그 수명은 렌더러의 것입니다. 얼마나 머무를지, 어디에 앉을지, 첫 번째가 아직 떠 있는데 두 번째가 오면 어떻게 할지 모두 그렇습니다. 그래서 메시지는 띄워 보내고 잊는 것이며, 지금 호출이 만들어 내는 배치에 실려 나갑니다.
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를 지나므로, 역할로 칠한 차트는 다른 모든 것처럼
디자인 시스템을 따릅니다.
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()가 그 통로입니다.
let window = use_window_size();
rsx! {
if window.is_expanded() {
Row { Sidebar {} Content {} }
} else {
Content {}
}
}
| 클래스 | 너비 | 모양 |
|---|---|---|
WindowSizeClass::Compact | 600 dp 미만 | 휴대폰 모양. 한 단. |
WindowSizeClass::Medium | 600 dp 이상 840 dp 미만 | 태블릿 모양. |
WindowSizeClass::Expanded | 840 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부터 시작하므로 나중에 추가되는 코어 위젯과 절대 부딪히지
않습니다.