Skip to main content

todo/
todo.rs

1//! A todo list — the interactive shape the other examples leave out.
2//!
3//! `settings.rs`, `inbox.rs` and `dashboard.rs` are all screens you look at.
4//! This one you *use*: typing into a field and submitting, toggling rows,
5//! deleting them, and an empty state that appears and leaves with a transition.
6//!
7//! ```sh
8//! cargo run -p swiftui-native --example todo
9//! cargo run -p swiftui-native --example todo -- --render
10//! ```
11
12use std::sync::{Mutex, OnceLock};
13use swiftui_native::prelude::*;
14use swiftui_native::{state, views};
15
16#[derive(Clone)]
17struct Item {
18    title: String,
19    done: bool,
20}
21
22/// The list itself, on the Rust side. SwiftUI never owns it — it reads it when
23/// it evaluates the body, which is the whole arrangement.
24fn items() -> &'static Mutex<Vec<Item>> {
25    static ITEMS: OnceLock<Mutex<Vec<Item>>> = OnceLock::new();
26    ITEMS.get_or_init(|| {
27        Mutex::new(vec![
28            Item { title: "Read the field offsets from the metadata".into(), done: true },
29            Item { title: "Draw the ring as an arc".into(), done: false },
30        ])
31    })
32}
33
34/// What the user is typing, which the field writes into directly.
35fn draft() -> &'static views::BoundState<String> {
36    static DRAFT: OnceLock<views::BoundState<String>> = OnceLock::new();
37    DRAFT.get_or_init(|| views::BoundState::new(String::new()))
38}
39
40fn add() {
41    let text = draft().get();
42    if text.trim().is_empty() {
43        return;
44    }
45    items().lock().unwrap().push(Item { title: text, done: false });
46    draft().set(String::new());
47    state::refresh_animated(state::ease_in_out(0.25));
48}
49
50fn toggle(i: usize) {
51    let mut list = items().lock().unwrap();
52    if let Some(it) = list.get_mut(i) {
53        it.done = !it.done;
54    }
55    drop(list);
56    state::refresh();
57}
58
59fn row(i: usize, it: &Item) -> View {
60    hstack_spacing(
61        &[
62            // The checkmark is the button, so the whole symbol is the target.
63            button_labelled(
64                system_image(if it.done { "checkmark.circle.fill" } else { "circle" })
65                    .symbol_rendering_mode(Some(views::SymbolRenderingMode::Hierarchical))
66                    .view()
67                    .fg_system(if it.done { SystemColor::Green } else { SystemColor::Gray }),
68                move || toggle(i),
69            )
70            .button_style(views::ButtonStyle::Bordered),
71            text(&it.title)
72                .strikethrough_styled(it.done, LinePattern::Solid, None)
73                .fg_system(if it.done { SystemColor::Gray } else { SystemColor::Primary }),
74            spacer(),
75        ],
76        10.0,
77    )
78    .padding_edges(EdgeSet::Vertical, Some(6.0))
79    .a11y_label(&it.title)
80    .a11y_value(if it.done { "Done" } else { "Not done" })
81}
82
83fn body() -> View {
84    let todos = items().lock().unwrap().clone();
85    let remaining = todos.iter().filter(|i| !i.done).count();
86
87    vstack_aligned_spacing(
88        &[
89            hstack![
90                text("Todo").text_style(TextStyle::LargeTitle).bold(),
91                spacer(),
92                text!("{remaining} left").fg_system(SystemColor::Gray),
93            ],
94            hstack_spacing(
95                &[
96                    textfield_bound("What needs doing?", draft())
97                        .text_field_style(views::TextFieldStyle::RoundedBorderTextFieldStyle)
98                        .on_submit(SubmitTrigger::Text, add)
99                        .submit_label(SubmitLabel::Done),
100                    button("Add", add).button_style(views::ButtonStyle::BorderedProminent),
101                ],
102                8.0,
103            ),
104            // The empty state is a branch, so it arrives and leaves rather than
105            // appearing from nothing.
106            view_if!(
107                todos.is_empty(),
108                vstack_spacing(
109                    &[
110                        system_image("checkmark.seal").view().font_size(40.0)
111                            .fg_system(SystemColor::Green),
112                        text("Nothing left to do").fg_system(SystemColor::Gray),
113                    ],
114                    8.0,
115                )
116                .frame_optional(None, Some(160.0), Alignment::Center)
117                .transition(Transition::ScaleWithOpacity { scale: 0.8, anchor: (0.5, 0.5) }),
118                list(for_each_items(
119                    &todos.iter().cloned().enumerate().collect::<Vec<_>>(),
120                    |(i, it)| row(*i, it),
121                ))
122                .list_style(views::ListStyle::PlainListStyle)
123            ),
124        ],
125        HorizontalAlignment::Leading,
126        Some(14.0),
127    )
128    .padding(20.0)
129}
130
131fn content_view() -> swiftui_native::ViewHandle {
132    state::root_view(|_| body().into_handle())
133}
134
135#[cfg(target_os = "macos")]
136#[path = "common/simulator.rs"]
137mod simulator;
138
139#[cfg(target_os = "macos")]
140fn main() {
141    if std::env::args().any(|a| a == "--sim" || a == "device") {
142        return simulator::main(&simulator::Bundle {
143            example: "todo",
144            bundle_id: "dev.rswift.Todo",
145            executable: "Todo",
146            display_name: "Todo",
147            launch_args: &[],
148        });
149    }
150    if std::env::args().any(|a| a == "--render") {
151        let out = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/todo.png");
152        swiftui_native::render::render(&content_view(), 420, 460).write_png(out);
153        eprintln!("wrote {out}");
154        return;
155    }
156    show_window(&content_view(), "Todo", 420.0, 460.0);
157}
158
159#[cfg(not(target_os = "macos"))]
160fn main() {
161    swiftui_native::app::run(content_view)
162}