dioxus-compose 가이드

UI 작성하기

rsx!, 위젯, 이벤트

Dioxus를 써 봤다면 대부분 익숙할 것입니다. 컴포넌트, 훅, 시그널, rsx!는 다른 렌더러에서와 똑같이 동작합니다. 이 프로젝트에만 있는 것은 닫힌 위젯 스키마, 키 이벤트를 소비하는 방식, 그리고 조합 중인 텍스트가 경계를 넘지 않는다는 규칙입니다.

앱의 뼈대

애플리케이션은 Element를 돌려주는 함수 하나와 launch 호출입니다. prelude가 위젯과 훅, 매크로를 함께 가져옵니다.

rust
use dioxus_compose::prelude::*;

fn app() -> Element {
    let mut count = use_signal(|| 0i32);

    rsx! {
        Column {
            fill_max_width: true,
            Text { text: format!("count: {}", count()) }
            Button {
                text: "increment",
                on_click: move |_| count += 1,
            }
        }
    }
}

fn main() {
    dioxus_compose::launch(app);
}

launchLaunchBuilder::new().launch(app)의 축약입니다. 빌더가 갖는 설정은 지금은 루프 모드 하나입니다.

rust
LaunchBuilder::new()
    .with_mode(LoopMode::Renderer)  // 데스크톱·iOS: Rust main이 렌더러를 돌립니다
    .launch(app);

LoopMode::Renderer가 기본값이고, 지금 실제로 도는 유일한 모드입니다. Android나 브라우저가 루프를 소유하고 Host를 불러 주는 LoopMode::Platform은 프로토콜에 정의만 되어 있고 구현은 없습니다.

위젯 집합

스키마는 의도적으로 닫혀 있습니다. 렌더러는 정확히 이 여덟 가지만 해석하고, 모르는 타입을 받으면 크래시 대신 ProtocolError 이벤트를 보냅니다. 위젯을 추가한다는 것은 Rust 스키마, Kotlin 인터프리터, 코드젠을 함께 넓히는 일입니다.

위젯속성메모
Columnfill_max_width, fill_max_height, children세로로 쌓습니다.
Rowfill_max_width, fill_max_height, children가로로 쌓습니다.
Boxfill_max_width, fill_max_height, children겹쳐 놓는 컨테이너. 아래 이름 주의 사항을 보세요.
TexttextString으로 변환되는 값이면 됩니다.
TextFieldplaceholder, enabled, multiline비제어 위젯입니다. value 속성이 없는 것은 의도입니다.
Buttontext, enabledenabled의 기본값은 true입니다.
Spacerwidth, height둘 다 f32이고 기본값은 0.0입니다.
LazyColumnitem_count, buffer, key_of, item윈도잉 목록. Host 쪽은 동작하고 렌더러는 아직 Column으로 그립니다. 목록과 스트리밍을 보세요.
Box가 아니라 dioxus_compose::Box

dioxus-core 0.7의 rsx! 전개 코드는 내부에서 한정 없는 Box<T>를 씁니다. Compose의 Box를 glob prelude로 가져오면 std::boxed::Box를 가려서 이 전개가 깨집니다. 그래서 prelude에서 일부러 빼 두었습니다. upstream이 자기 쪽 이름을 한정하기 전까지는 전체 경로로 쓰세요.

rust, dioxus-compose/tests/rsx_api.rs
rsx! {
    dioxus_compose::Box {
        fill_max_width: true,
        Text { text: "qualified Box" }
    }
}

Modifier

Modifier는 값의 리스트로 직렬화됩니다. 예를 들면 [Padding(16), FillMaxWidth, Background(argb), Clickable(handler_id)]이고, 렌더러가 이것을 Compose Modifier 체인으로 되돌립니다. 지금 프로토콜에 있는 변형은 Empty, Padding, FillMaxWidth, FillMaxHeight, Width, Height, Size, Background, Clickable입니다.

지금 실제로 쓸 수 있는 것

rsx! 속성으로 노출된 것은 Column, Row, Boxfill_max_widthfill_max_height뿐입니다. 패딩, 명시적 크기, 배경색, Clickable은 프로토콜과 렌더러에는 있지만 그것을 내보내는 속성이 아직 없습니다. 나머지 변형은 API가 자라 갈 방향이라고 보면 됩니다.

이벤트 핸들러

핸들러는 평범한 Rust 클로저입니다. 그리고 동기로 호출됩니다. 렌더러가 자기 UI 스레드에서 Host를 부르고, 핸들러가 실행되고, diff가 계산되고, 그 결과 배치가 같은 호출에서 돌아옵니다. 큐에 쌓이는 것은 없습니다.

핸들러페이로드대상
on_click()Button
on_value_changeStringTextField
on_submitStringTextField
on_focus_lost()TextField
on_key_downKeyEventTextField
on_range_requestedRangeRequestLazyColumn, 컴포넌트 내부에서 처리합니다. 대신 itemkey_of를 넘깁니다

핸들러는 UI 스레드에서 돌기 때문에 가벼워야 합니다. PTY 읽기, 네트워크, 파일처럼 막히는 작업은 Host 워커 스레드의 몫입니다. 워커는 시그널만 갱신하고, 프레임 요청은 Host가 알아서 합니다. 경계 함수를 직접 부를 일은 없습니다.

이벤트 소비: Enter와 Shift+Enter

채팅 입력창에서는 Enter가 전송이고 Shift+Enter가 줄바꿈이어야 합니다. 그러려면 이 키를 처리했는지를 렌더러에게 동기로 알려 줘야 합니다. 웹의 preventDefault(), Compose의 PointerInputChange.consume()과 같은 모델입니다.

Dioxus 0.7의 핸들러는 반환값이 없습니다. 그래서 소비 표시를 이벤트 객체에 남기고, 경계가 그 값을 읽어 렌더러에 돌려줍니다. 렌더러 쪽에서는 Modifier.onKeyEvent가 처리됨으로 보고합니다.

rust, dioxus-compose/examples/desktop_demo.rs
TextField {
    placeholder: "Write a message",
    multiline: true,
    on_value_change: move |value| draft.set(value),
    on_key_down: move |event: KeyEvent| {
        if event.key() == Key::Enter && !event.shift_key() {
            let message = draft().trim().to_owned();
            if !message.is_empty() {
                messages.write().push(message);
                draft.set(String::new());
            }
            event.consume();   // 렌더러의 onKeyEvent가 true를 반환합니다
        }
    }
}

KeyEventkey(), shift_key(), ctrl_key(), alt_key(), meta_key()consume(), consumed()를 제공합니다. Key 열거형에는 지금 Key::Enter 하나뿐입니다. M0 스키마가 나르는 유일한 키이기 때문이고, 키를 늘리는 것은 앱 코드가 아니라 스키마를 고치는 일입니다.

IME 조합 중의 Enter

IME가 조합 중일 때 렌더러는 키 이벤트를 Host로 아예 보내지 않습니다. 조합 중의 Enter는 "제출"이 아니라 "이 글자를 확정"입니다. 이 규칙을 어기면 한글을 치다가 Enter를 눌렀을 때 조합 중이던 글자가 빠진 채 메시지가 전송됩니다. 핸들러는 그런 키 입력을 애초에 보지 못하고, 그래서 위의 단순한 조건이 편의가 아니라 정확한 코드입니다.

비제어 TextField

TextField에는 value 속성이 없습니다. 편집 버퍼와 선택 영역, 조합 상태는 렌더러의 것입니다. Rust는 이벤트로만 변화를 알게 됩니다.

이것은 단순화가 아니라 설계의 핵심입니다. 조합 중인 텍스트를 Host로 왕복시키면, 한글 한 글자를 만드는 동안의 모든 입력이 렌더러를 떠났다가 되돌아오며 조합을 리셋합니다. 한글은 자모를 모아 글자를 만들기 때문에 조합 도중의 리셋은 입력 자체를 망가뜨립니다. 일본어와 중국어 입력도 마찬가지입니다. 버퍼를 Kotlin에 두면 이 실패가 드문 일이 아니라 불가능한 일이 됩니다.

Host가 정말로 텍스트를 바꿔야 할 때, 전송 후 입력창을 비우거나 임시 저장한 초안을 되돌릴 때, 는 명시적인 SetText(node_id, text, selection) 명령을 쓰고, 렌더러는 조합이 끝날 때까지 적용을 미룹니다. Rust 쪽에서는 Host::set_text이며, 컴포넌트에서 부르는 API가 아니라 경계 표면의 일부입니다. 컴포넌트 코드에서 입력창을 비우는 보통의 방법은 필드에 새 정체성을 주는 것으로, 다른 Dioxus 렌더러에서와 같습니다.

상태는 어디에 있나

상태소유자이유
애플리케이션·도메인 상태Rust (시그널)당신의 프로그램입니다. Dioxus가 이것을 mutation으로 바꿉니다.
편집 중인 텍스트, IME 조합Kotlin조합은 왕복해서는 안 됩니다(위 참고).
스크롤 위치KotlinUI 로컬 상태입니다. Rust가 쥐고 있으면 한 프레임 늦습니다.
포커스Kotlin같은 이유이고, 애초에 플랫폼이 포커스를 소유합니다.
애니메이션 진행KotlinCompose 프레임 클록 위에서 돕니다.