Guides
Navigation
Stacks, tabs, sheets, alerts and menus, links into the app, and shares from other apps.
An app's structure is made of stacks of screens, tabs, sheets, alerts
and menus. Each one is the platform's own: a UINavigationController
on iOS, a top app bar with Material transitions and predictive back on
Android. This page covers them, along with the back gesture, and the
links and shares that open the app. examples/navigation
has all of them.
Stacks
ui.Stack is a navigation stack. It starts with a root screen, and
components in it push more with the navigator from ui.UseNavigator.
func App() ui.Node {
return ui.Stack(Notes, ui.ScreenOptions{Title: ui.Val("Notes")})
}
func Notes() ui.Node {
nav := ui.UseNavigator()
return ui.List(ui.ListProps{}, notes, func(n note) int { return n.ID },
func(n signal.Accessor[note], _ signal.Accessor[int]) ui.Node {
return ui.View(ui.ViewProps{
OnPress: func() {
v := n.Get() // in a handler: not tracked
nav.Push(func() ui.Node { return Detail(v) }, ui.ScreenOptions{Title: ui.Val(v.Text)})
},
Style: ui.Style{Padding: ui.Symmetric(12, 20)},
}, ui.Text(signal.Func(func() string { return n.Get().Text })))
})
}
func Detail(n note) ui.Node {
nav := ui.UseNavigator()
return ui.SafeArea(ui.Style{Padding: ui.All(20), Gap: 12},
ui.StaticText(n.Text),
ui.Button(ui.ButtonProps{Title: ui.Val("Done"), OnPress: nav.Pop}),
)
}nav.Push(screen, options)shows a screen on top, with the platform's push animation.nav.Pop()removes the top screen. It never pops the root.nav.Depth()is how many screens the stack holds.
Call ui.UseNavigator while the component is being built, as above, and
keep the navigator for its handlers. It panics outside a stack.
A stack fills the space it's given. Each screen is an owner: popping it, by code or by the user's back gesture, disposes it, stopping its effects. A screen pushed again starts fresh, so keep what must outlive it in a signal outside it, or in storage.
Screen options
ui.ScreenOptions configures a screen of a stack, given to Stack for
the root and to Push for the others.
Titleis the navigation bar's title (the top app bar's on Android). It's an accessor, so it can change.Actionsare the bar's buttons.NoBackGestureturns the back gesture off for the screen (see Back).Searchputs a search field in the bar.ZoomFromzooms the screen out of a view on the screen below.
Bar buttons
Each action is a ui.Action. With an Icon, the bar shows the icon
instead of the title, and VoiceOver and TalkBack read the title.
nav.Push(func() ui.Node { return Detail(n) }, ui.ScreenOptions{
Title: ui.Val(n.Text),
Actions: []ui.Action{
{Title: "Share", Icon: ui.IconShare, OnPress: func() { ui.Share(n.Text) }},
{Title: "Delete", Role: ui.ActionDestructive, OnPress: func() { nav.Pop() }},
},
})On Android, actions go in the top app bar as there's room, and the rest go in its overflow menu. A long press on an icon shows its title.
Search
Search puts a search field in the bar, as SwiftUI's searchable does.
Typing writes Text, and writing Text from code changes the field.
Placeholder is "Search" when it's empty, and OnSubmit runs on the
keyboard's search key.
query := signal.New("")
shown := signal.Func(func() []note {
var out []note
for _, n := range notes.Get() {
if strings.Contains(strings.ToLower(n.Text), strings.ToLower(query.Get())) {
out = append(out, n)
}
}
return out
})
return ui.Stack(func() ui.Node {
return ui.List(ui.ListProps{}, shown, func(n note) int { return n.ID },
func(n signal.Accessor[note], _ signal.Accessor[int]) ui.Node {
return ui.Text(signal.Func(func() string { return n.Get().Text }), ui.Style{Padding: ui.Symmetric(12, 20)})
})
}, ui.ScreenOptions{Title: ui.Val("Notes"), Search: &ui.Search{Text: query, Placeholder: "Search notes"}})On iOS it's a UISearchController under the title, which stays shown
as the screen scrolls. On Android it's Material's pattern: a search
button in the top app bar that expands into the field.
Zooming from a view
ZoomFrom is the Style.Match key of a view on the screen below, such
as a photo in a grid or a card. The screen zooms out of that view as
it's pushed, and back into it as it's popped. See
shared elements.
key := fmt.Sprintf("photo-%d", id)
return ui.View(ui.ViewProps{
Label: "Photo",
Style: ui.Style{AspectRatio: 1, Match: key},
OnPress: func() {
nav.Push(func() ui.Node { return photo(id) }, ui.ScreenOptions{Title: ui.Val("Photo"), ZoomFrom: key})
},
})On iOS this is the system's zoom transition (iOS 18 and later; before
that, the screen pushes as usual). Android does its own zoom, and
popping with the predictive back gesture goes back into the view.
examples/gallery's photo grid does this.
Back
Each platform goes back its own way, and RyUI leaves it so:
- iOS: the bar's back button, and a swipe from the screen's leading
edge.
NoBackGestureturns the swipe off. The back button still pops. - Android: the system back, by the button or the gesture, and the
up arrow in the top app bar. Back pops the innermost stack on screen
that has a screen to go back to: in tabs, the shown tab's stack. With
the predictive back gesture, the top screen shrinks and follows the
finger, showing the one below. When no stack has a screen to pop,
back does what it does in any app, and leaves it.
NoBackGestureturns off the predictive animation, but back still pops the screen.
Whichever way the user goes back, the screen is disposed, as with
nav.Pop(). Back closes an open sheet or dialog first.
Tabs
ui.Tabs shows one page at a time, with a tab bar: a
UITabBarController on iOS, Material's bottom navigation bar on
Android. Each ui.Tab has a Title, an Icon and its Content.
Selected is the shown tab's index. Tapping a tab writes it, and
writing it switches tabs.
tab := signal.New(0)
return ui.Tabs(ui.TabsProps{Selected: tab},
ui.Tab{Title: "Notes", Icon: ui.IconList, Content: func() ui.Node {
return ui.Stack(Notes, ui.ScreenOptions{Title: ui.Val("Notes")})
}},
ui.Tab{Title: "Settings", Icon: ui.IconSettings, Content: func() ui.Node {
return ui.Stack(Settings, ui.ScreenOptions{Title: ui.Val("Settings")})
}},
)Give each tab its own Stack so that it has a bar and keeps its own
history, as the platforms' apps do. Every tab's content is built when
the Tabs is, and kept while you switch between them. Tab icons are
symbols, like a button's: see Symbols and icons.
On Android an icon must be one that Android has.
Sheets
ui.Sheet presents content in a modal sheet while Open is true. On
iOS it's a sheet presentation with detents, and on Android a
BottomSheetDialog. The node it returns takes no space, so put it
anywhere in the screen that opens it.
open := signal.New(false)
note := signal.New("")
return ui.Column(nil,
ui.Button(ui.ButtonProps{Title: ui.Val("Add a note"), OnPress: func() { open.Set(true) }}),
ui.Sheet(ui.SheetProps{
Open: open,
Detents: ui.DetentMedium | ui.DetentLarge,
Grabber: true,
Content: func() ui.Node {
draft := signal.New(note.Peek())
return ui.SafeArea(ui.Style{Padding: ui.All(20), Gap: 12},
ui.TextField(ui.TextFieldProps{Value: draft, Placeholder: ui.Val("Write a note")}),
ui.Button(ui.ButtonProps{Title: ui.Val("Save"), Variant: ui.ButtonFilled, OnPress: func() {
note.Set(draft.Peek())
open.Set(false)
}}),
)
},
OnDismiss: func() { fmt.Println("dismissed, not saved") },
}),
)Detentsare the heights it can rest at:ui.DetentMedium(half the screen),ui.DetentLarge(all of it), or both, which starts at medium and lets the user drag it up. With none, it's large. When the keyboard shows, a sheet that can be large goes large.Grabbershows the handle at its top.Contentis built when it opens and disposed when it closes, so state made in it starts fresh each time.
The user dismisses a sheet by swiping it down, or on Android with back
or a tap outside it. That sets Open to false and calls OnDismiss.
Setting Open to false from code closes it without calling
OnDismiss. So OnDismiss means "closed without your say", such as a
draft left unsaved.
Alerts and action sheets
ui.Alert shows an alert now: a UIAlertController on iOS, a Material
dialog on Android. Pressing a button closes the alert and runs that
button's OnPress. With no Actions, it has a single OK.
ui.Alert(ui.AlertProps{
Title: "Delete this note?",
Message: "You can't undo this.",
Actions: []ui.Action{
{Title: "Cancel", Role: ui.ActionCancel},
{Title: "Delete", Role: ui.ActionDestructive, OnPress: remove},
},
})An action's Role says what it is:
ui.ActionDefaultis an ordinary button.ui.ActionCancelis the way out. iOS puts it in its own place, and Android makes it the dialog's negative button.ui.ActionDestructiveis red: in iOS's style, and in Material's error color on Android.
On Android, the last other button is the dialog's positive button, at the end, and a second one is neutral. With more than two buttons other than Cancel, the dialog shows them as a list.
ActionSheet shows the actions as an action sheet, a list of choices
coming up from the bottom on iOS:
ui.Alert(ui.AlertProps{
Title: "Photo",
ActionSheet: true,
Actions: []ui.Action{
{Title: "Take a photo", OnPress: func() {}},
{Title: "Choose from library", OnPress: func() {}},
{Title: "Cancel", Role: ui.ActionCancel},
},
})On Android an action sheet is a dialog with the actions as a list, and
its Message isn't shown. An alert closed some other way, such as back
or a tap outside it on Android, runs no button's OnPress.
Menus
A menu shows a list of ui.Actions, the same type as alert buttons and
bar buttons, with an optional Icon beside each title. There are two:
- A
ui.ButtonwithMenuopens it when tapped. ui.Menuputs one on any content: on a tap, or withLongPressas a context menu.
On iOS it's a UIMenu, and on Android a PopupMenu. Both are in
Components. Rows of a List have swipe actions
too: see Lists.
Toasts
RyUI has no toast component, since neither platform has a standard one
that apps share. Make one from a view placed over the screen,
shown with ui.Show and brought in with a
transition. The gallery's toast does this, and
calls ui.Announce so that screen readers say it too. See
examples/gallery.
Deep links
An app opens for URLs of its own scheme, and for https links to its own
domains, when ryui.json lists them:
{
"name": "Notes",
"id": "com.example.notes",
"scheme": "notes",
"links": ["notes.example.com"]
}schemeopens the app fornotes://anything.linksopens it forhttps://notes.example.com/...: universal links on iOS, App Links on Android. The domain must say so too. It servesapple-app-site-associationfor iOS andassetlinks.jsonfor Android, as each platform's docs describe.
ui.OnOpenURL gets each URL the app is opened with, at launch or while
it runs. The URL that launched the app comes before any handler exists,
so it goes to the first handler. The handler stops with its component's
owner. Route from it with the navigator, or by setting a signal.
ui.OnOpenURL(func(raw string) {
u, err := url.Parse(raw)
if err != nil {
return
}
if u.Host == "add" {
fmt.Println("add", u.Query().Get("text"))
}
})examples/notes adds a note for
ryuinotes://add?text=.... A notification's URL arrives the same way
(see Device).
Shares from other apps
With "shareTarget": true in ryui.json, the app shows in other apps'
share sheets, for text and links, such as "Share" in a browser or in
YouTube. ui.OnShare gets what's shared. A share that launched the app
goes to the first handler, as with OnOpenURL.
ui.OnShare(func(text string) {
fmt.Println("shared:", text)
})On iOS the app comes with a share extension, which opens the app with
what's shared, as Android opens it: the extension shows no screen of
its own. What's shared is often a title and a link together (a page's
title and its URL from Safari, a line each), so look for the link in the
text. examples/downloader does
this.
To share from your app instead, ui.Share(text) shows the system's
share sheet.