Guides
Components
Text, buttons, inputs, images, lists, video, web views and drawing.
A component is a Go function that returns a ui.Node. This page covers
the views package ui gives you, grouped by what they do, and the
packages next to it for Markdown and SVG. Each one is the platform's own
widget where the platform has one (UIKit on iOS, Material 3 on Android).
How views are sized and placed is in Layout; screens, tabs
and sheets are in Navigation.
Most props that can change take a signal.Accessor: pass a signal, a
signal.Func, or ui.Val(v) for a value that never changes. A control
that the user changes (a field, a toggle) takes a *signal.Signal
instead, and writes it. See Concepts.
Text
Text and StaticText
ui.Text shows a line or paragraph of text. It is the platform's label,
and wraps by default; Style.Lines limits how many lines it takes, and
cuts the rest with an ellipsis. ui.StaticText is a Text whose text
never changes.
count := signal.New(0)
label := signal.Func(func() string { return fmt.Sprintf("%d unread", count.Get()) })
return ui.Column(ui.Style{Gap: 8},
ui.StaticText("Inbox", ui.Style{Font: ui.Title2.Weighted(ui.Bold)}),
ui.Text(label, ui.Style{Color: ui.SecondaryLabel}),
ui.StaticText("A long description that stops after two lines.", ui.Style{Lines: 2}),
)The style's Font, Color, TextAlign and Lines are the text's.
Text styles (ui.Body, ui.Headline, ui.Footnote...) follow the
text size the user picked.
RichText
ui.RichText shows styled text: a ui.Rich, a list of spans. A "\n"
in a span starts a new paragraph. A span's fields left zero come from
the RichText's style.
ui.RichText(ui.RichTextProps{
Text: ui.Val(ui.Rich{
ui.Strong("ryui"), ui.Plain(" draws "), ui.Em("native"), ui.Plain(" views. Run "),
ui.Code("ryui dev"), ui.Plain(", or read "), ui.Link("the docs", "https://example.com/docs"),
ui.Plain(".\nA second paragraph, "), ui.Plain("in red").Colored(ui.Red), ui.Plain("."),
}),
OnLink: ui.OpenURL,
})Plain,Strong,Em,CodeandLinkmake spans.Colored,HighlightedandWeightedreturn a span with a color, a background or a weight.- A
ui.Spanhas the rest:Font,Italic,Mono,Underline,Strike,Background(drawn on each line the span covers, with rounded corners). - A tapped link calls
OnLinkwith its URL. WithoutOnLink, links do nothing. Role: ui.RoleHeadermakes it a heading to screen readers.
Each paragraph is typeset by the platform's engine (CoreText,
StaticLayout) and cached by its content and width. Appending to a long
text, a reply streaming in, typesets only the paragraph that changed.
examples/richtext streams one.
For plain text, use Text: it is the platform's label, with its native
behavior.
Inline views. ui.Embed puts a component in the text: it sits on
the baseline and wraps with the words. Its key keeps the same component,
and its state, from one Rich to the next.
ui.RichText(ui.RichTextProps{Text: ui.Val(ui.Rich{
ui.Plain("Status: "),
ui.Embed("badge", func() ui.Node {
return ui.View(ui.ViewProps{Style: ui.Style{Background: ui.Green, CornerRadius: 6, Padding: ui.Symmetric(2, 6)}},
ui.StaticText("live", ui.Style{Font: ui.Caption, Color: ui.RGB(255, 255, 255)}))
}),
ui.Plain(" since this morning."),
})})Selectable text
ui.Selectable makes the RichText views inside it selectable, as one
text across all of them: a long press selects a word, the platform's
handles extend the selection, and its edit menu copies it.
ui.Selectable(ui.SelectableProps{Style: ui.Style{Gap: 12}},
ui.RichText(ui.RichTextProps{Text: ui.Val(question), Role: ui.RoleHeader}),
ui.RichText(ui.RichTextProps{Text: ui.Val(answer)}),
)Text is not selectable; use a RichText in a Selectable. To copy
something without a selection, ui.Copy(text) puts it on the clipboard.
Markdown
Package ryui/markdown shows Markdown as native views, built from
RichText. It parses as text is appended: only the block being written
parses again, and only its views update.
markdown.View(markdown.Props{Text: reply, Streaming: writing})Streaming says the text is still being written, so a half-written
**bold shows as bold rather than as asterisks. The view is one
selection by default (NotSelectable turns that off); links open with
ui.OpenURL unless you give OnLink; Theme changes fonts, colors and
spacing; Code shows code blocks your own way. It reads CommonMark and
GitHub's extensions. See examples/markdown.
Buttons
ui.Button is the platform's button: a UIButton configuration on
iOS, a Material button on Android.
saving := signal.New(false)
return ui.Row(ui.Style{Gap: 12, Wrap: true},
ui.Button(ui.ButtonProps{Title: ui.Val("Save"), Variant: ui.ButtonFilled, Disabled: saving,
OnPress: func() { saving.Set(true) }}),
ui.Button(ui.ButtonProps{Title: ui.Val("Compose"), Icon: ui.IconCompose, Variant: ui.ButtonTinted}),
ui.Button(ui.ButtonProps{Icon: ui.IconTrash, Label: "Delete", Variant: ui.ButtonGray}),
)Titleis its text;Icona symbol before it (see Symbols). A button with an icon and no title is the icon alone.Labelis what VoiceOver and TalkBack read, the title by default. Give an icon-only button one.Disableddisables it, the platform's way.Style.FontandStyle.Colorchange its text and tint.
Variant is its look:
| Variant | iOS | Android |
|---|---|---|
ButtonPlain (default) |
plain | text button |
ButtonFilled |
filled | filled button |
ButtonTinted |
tinted | tonal button |
ButtonGray |
gray | tonal button |
ButtonBordered |
bordered | outlined button |
Menus
A button with Menu shows its actions when it's tapped, instead of
calling OnPress (a UIMenu on iOS, a PopupMenu on Android). Each
ui.Action has a Title, an optional Icon beside it, a Role, and
OnPress. ui.ActionDestructive shows it in red.
ui.Button(ui.ButtonProps{Icon: ui.IconMore, Label: "More", Menu: []ui.Action{
{Title: "Copy", Icon: ui.IconCopy, OnPress: func() { ui.Copy("https://go.dev") }},
{Title: "Share", Icon: ui.IconShare, OnPress: func() { ui.Share("https://go.dev") }},
{Title: "Delete", Icon: ui.IconTrash, Role: ui.ActionDestructive, OnPress: func() {}},
}})ui.Menu puts a menu on any content. With LongPress it's a context
menu, shown on a long press:
ui.Menu(ui.MenuProps{LongPress: true, Actions: []ui.Action{
{Title: "Reply", Icon: ui.IconMessage},
{Title: "Delete", Role: ui.ActionDestructive},
}, Style: ui.Style{Padding: ui.All(16), Background: ui.SecondaryBackground, CornerRadius: 12}},
ui.StaticText("Long-press for a menu"),
)Text fields
ui.TextField is a text input. Value is its text: typing writes the
signal, and writing the signal from code replaces the text.
email := signal.New("")
password := signal.New("")
var pw ui.Node
pw = ui.TextField(ui.TextFieldProps{Value: password, Placeholder: ui.Val("Password"),
Secure: true, ContentType: "password", ReturnKey: ui.ReturnGo,
OnSubmit: func() { ui.Blur(pw) }})
em := ui.TextField(ui.TextFieldProps{Value: email, Placeholder: ui.Val("Email"),
Keyboard: ui.KeyboardEmail, ContentType: "email", Autocapitalize: ui.CapitalizeNone,
Autocorrect: ui.AutocorrectNo, ReturnKey: ui.ReturnNext, OnSubmit: func() { ui.Focus(pw) }})
return ui.Column(ui.Style{Gap: 12}, em, pw)Securehides what's typed, for passwords.Keyboard:KeyboardDefault,KeyboardNumber,KeyboardDecimal,KeyboardEmail,KeyboardURL,KeyboardPhone.ReturnKeyis the return key's label:ReturnDefault,ReturnDone,ReturnGo,ReturnNext,ReturnSearch,ReturnSend. Pressing it callsOnSubmit.ContentTypeis an AutoFill hint:"username","password","email","name"...Autocapitalize:CapitalizeSentences(the default),CapitalizeNone,CapitalizeWords,CapitalizeCharacters.Autocorrect:AutocorrectDefault,AutocorrectYes,AutocorrectNo.OnFocusandOnBlurfollow the keyboard.ui.Focus(field)asks for it,ui.Blur(field)dismisses it.
Lines of text. Multiline makes it a text area, for a message or a
comment: return starts a new line instead of calling OnSubmit, and it
grows with its text up to Style.Lines lines (0: no limit), then
scrolls.
message := signal.New("")
return ui.Row(ui.Style{Gap: 8, Align: ui.AlignEnd},
ui.TextField(ui.TextFieldProps{Value: message, Placeholder: ui.Val("Message"), Multiline: true,
Style: ui.Style{Grow: 1, Lines: 5}}),
ui.Button(ui.ButtonProps{Icon: ui.IconSend, Label: "Send", Variant: ui.ButtonFilled,
Disabled: signal.Func(func() bool { return message.Get() == "" }),
OnPress: func() { message.Set("") }}),
)A SafeArea is padded by the keyboard, so a field at the bottom of one
stays above it.
Controls
Each control takes the signal it changes, and a Disabled accessor.
None of them has a label of its own: put a Text beside it.
return ui.Column(ui.Style{Gap: 16},
ui.Row(ui.Style{Align: ui.AlignCenter},
ui.StaticText("Wi-Fi", ui.Style{Grow: 1}),
ui.Toggle(ui.ToggleProps{Value: wifi})),
ui.Slider(ui.SliderProps{Value: volume}),
ui.Segmented(ui.SegmentedProps{Segments: []string{"Day", "Week", "Month"}, Selected: period}),
ui.Row(ui.Style{Gap: 12, Align: ui.AlignCenter},
ui.Text(signal.Func(func() string { return fmt.Sprintf("%.0f cups", cups.Get()) }), ui.Style{Grow: 1}),
ui.Stepper(ui.StepperProps{Value: cups, Min: 1, Max: 8})),
ui.Wheel(ui.WheelProps{Items: sizes, Selected: size}),
ui.Row(ui.Style{Gap: 12, Align: ui.AlignCenter},
ui.StaticText("Tint", ui.Style{Grow: 1}),
ui.ColorPicker(ui.ColorPickerProps{Value: tint, Alpha: true, Title: "Tint"})),
ui.Row(ui.Style{Gap: 12, Align: ui.AlignCenter},
ui.StaticText("Starts", ui.Style{Grow: 1}),
ui.DatePicker(ui.DatePickerProps{Value: when, Mode: ui.DateAndTime, Min: time.Now()})),
)- Toggle is a switch,
*signal.Signal[bool]. - Slider picks a
float64fromMintoMax; with both 0, from 0 to 1. - Segmented is one of a few options side by side,
Selectedthe index: UIKit's segmented control, Material's segmented buttons. - Stepper steps a
float64with minus and plus, byStep(default 1), fromMintoMax(both 0: 0 to 100). It's aUIStepperon iOS; Android has none, so it's two Material icon buttons. - Wheel picks one of many
Itemson a spinning wheel:UIPickerView, Android'sNumberPicker. - ColorPicker is a swatch that opens the platform's picker, writing
a
ui.Color.Alphalets the user pick an opacity,Titlenames the picker. On iOS it'sUIColorWelland the system's picker; Android has none, so it's a Material dialog of a palette, with an opacity slider forAlpha. - DatePicker picks a
time.Time, shown in the device's time zone.Modeisui.DateOnly(the default),ui.TimeOnlyorui.DateAndTime;MinandMaxbound it. On iOS it's UIKit's compact date picker; on Android, buttons showing the date and time that open Material's pickers. A zeroValueshows the current time.
Progress and activity
ui.Progress is a progress bar: its value from 0 to 1, or nil when
how far is unknown. Its style's Color is its fill. ui.Activity is a
spinner.
return ui.Column(ui.Style{Gap: 12},
ui.Progress(done),
ui.Progress(nil),
ui.Activity(),
)Images
ui.Image shows a picture. The platform loads and decodes it off the
main thread, and caches it: in memory, and on disk across launches when
it came over http(s).
return ui.Column(ui.Style{Gap: 12},
ui.Image(ui.ImageProps{Source: ui.Val("logo.png"), Style: ui.Style{Width: ui.Pt(64)}}),
ui.Image(ui.ImageProps{
Source: ui.Val("https://picsum.photos/id/1015/600/400"),
Placeholder: "placeholder.png",
Mode: ui.ImageFill,
Label: "A river in a valley",
Style: ui.Style{AspectRatio: 3.0 / 2, CornerRadius: 12, Clip: true},
}),
)Sourceis an http(s) URL, a path in the app'sassetsdirectory ("logo.png"isassets/logo.png), or an absolute path: a file the app wrote, a photo the user picked.Modeis how it fills its box:ImageFit(the default, all of it, letterboxed),ImageFill(covers the box, cropped),ImageStretch(the box's aspect ratio),ImageCenter(its natural size, centered).Placeholderis shown untilSourceloads, and if it fails. Use an asset, which is there at once. Give the image a size so the placeholder has room.Labeldescribes it to screen readers; without one, the image is decorative.OnLoadgets its natural size once it's loaded;OnErrora message when it can't be.
Until it has loaded, an image without a size is empty. Then it takes its
natural size, one point per pixel, no wider than the space it has. With
one side set (a Width, or Grow in a row) it keeps its aspect ratio;
AspectRatio sets one before it loads.
Symbols and icons
ui.Symbol shows a symbol. It's an SF Symbol on iOS, and the Material
Symbol for it on Android. It's sized as text in its style's Font, so
it follows the text size, and colored with its Color (the label color
by default).
return ui.Row(ui.Style{Gap: 14, Align: ui.AlignCenter},
ui.Symbol(ui.IconBell),
ui.Symbol(ui.IconStar.Filled(), ui.Style{Color: ui.Yellow, Font: ui.Title}),
ui.Symbol(ui.Icon("archivebox"), ui.Style{Color: ui.SecondaryLabel}),
)A ui.Icon is an SF Symbol name. Buttons, menu items, bar buttons and
tabs take one too. The Icon constants are the common ones:
IconHome, IconSearch, IconSettings, IconPlus, IconClose,
IconTrash, IconShare, IconMore and about fifty more.
icon.Filled() is its filled form ("star.fill").
iOS draws any SF Symbol. Android draws a curated set: the constants and
more, mapped to Material Symbols in
platform/android/gen_symbols.go. A
name outside that set shows nothing on Android (it logs a warning), so
check a name you write as a string, like "archivebox", on both.
SVG icons
For icons of your own, package ryui/svg draws SVG with the platform's
vector drawing; nothing is rasterized. svg.MustParse reads the markup
(svg.Parse returns an error instead), and svg.View draws it.
var download = svg.MustParse(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>`)
func downloadIcon() ui.Node {
return svg.View(svg.Props{Icon: download, Color: ui.Val(ui.Accent), Label: "Download",
Style: ui.Style{Width: ui.Pt(24), Height: ui.Pt(24)}})
}Color paints what the SVG paints with currentColor, and fills it
doesn't give; it's ui.Label, the text color, by default. Without a
width or height in its Style, the view is the SVG's own size; with
one, the other keeps the aspect ratio. Keep an icon in a file with
//go:embed and parse it once, in a package variable.
It reads what icon sets use (Lucide, Material, Heroicons, Feather):
paths, basic shapes, groups, use, fills, strokes and transforms. It
skips text, gradients (painted with the icon's color), clipping, masks,
filters and fill-rule="evenodd".
Video
ui.Video plays a video with the platform's player
(AVPlayerViewController, Media3's ExoPlayer) and its controls.
ui.Play, ui.Pause and ui.Seek drive it.
var v ui.Node
v = ui.Video(ui.VideoProps{
Source: ui.Val("https://example.com/clip.mp4"),
AutoPlay: true, Muted: true, Loop: true,
Style: ui.Style{CornerRadius: 12},
})
return ui.Column(ui.Style{Gap: 12}, v,
ui.Button(ui.ButtonProps{Title: ui.Val("Start over"), OnPress: func() { ui.Seek(v, 0); ui.Play(v) }}),
ui.Button(ui.ButtonProps{Title: ui.Val("Pause"), OnPress: func() { ui.Pause(v) }}),
)Sourceis an http(s) URL (a file, an HLS stream) or a file in the app's assets.Modeisui.ImageFit(letterboxed, the default) orui.ImageFill.HideControlshides the platform's controls: play, the timeline, full screen, and on iOS AirPlay and picture in picture.OnEndis called each time it plays to its end;OnErrorif it can't play.
It sizes itself as an Image does: empty until the video's size is
known, unless its style gives it one, then keeping the video's aspect
ratio. Seek takes seconds from the start.
Web pages
ui.WebView shows a web page in the platform's web view (WKWebView,
Android's WebView), with its scrolling, zooming and selection. With
no height and no Grow it grows to fill the space it's given.
state := signal.New(ui.WebState{})
var web ui.Node
web = ui.WebView(ui.WebViewProps{
URL: ui.Val("https://go.dev"),
Block: []string{"doubleclick.net", "/ads/"},
OnState: func(s ui.WebState) { state.Set(s) },
OnMessage: func(m string) { fmt.Println("the page says", m) },
OnNewWindow: func(url string) {
ui.OpenURL(url)
},
})
return ui.Column(ui.Style{Grow: 1},
ui.Row(ui.Style{Gap: 8, Padding: ui.Symmetric(8, 12), Align: ui.AlignCenter},
ui.Button(ui.ButtonProps{Icon: ui.IconBack, Label: "Back",
Disabled: signal.Func(func() bool { return !state.Get().CanGoBack }),
OnPress: func() { ui.WebBack(web) }}),
ui.Text(signal.Func(func() string { return state.Get().Title }), ui.Style{Grow: 1, Lines: 1}),
ui.Button(ui.ButtonProps{Icon: ui.IconRefresh, Label: "Reload", OnPress: func() { ui.WebReload(web) }}),
),
ui.Show(signal.Func(func() bool { return state.Get().Loading }), func() ui.Node {
return ui.Progress(signal.Func(func() float64 { return float64(state.Get().Progress) }))
}),
web,
)What it shows:
URLis an http(s) URL, or a file in the app's assets (its relative links are the assets' too).HTMLis markup to show instead.UserAgentis what pages are fetched with, andnavigator.userAgent; empty, the platform's.Preloadis script run before each page's own, in every frame: to hookfetch, or set globals.Blockkeeps requests from loading, as an ad blocker does. Each entry is a host, which matches its subdomains too ("doubleclick.net"), or, with a/, a part of the URL ("/ads/").
Preload, UserAgent and Block are set when the view is made.
What it reports:
OnLoadgets the URL of each page that has loaded;OnErrora message when one couldn't.OnStatefollows the page, as an address bar does: aui.WebStatewith itsURL,Title,Loading,Progress(0 to 1),CanGoBackandCanGoForward.OnNewWindowgets the URL the page would open in a new window (window.open, atarget="_blank"link). Without it, that page loads in this view.OnDownloadgets a response the page can't show, a file to save (a link to a.zip, aContent-Disposition: attachment), as aui.WebDownloadwith itsURL,MIME,FilenameandSize(-1 when unknown). It doesn't download it.OnRequestgets the URL of each request the page makes (scripts, images, media,fetch). Android only: iOS doesn't let an app see them.
ui.WebBack, ui.WebForward, ui.WebReload and ui.WebStop move
through its history, load the page again, or stop it loading.
Talking to the page
The page posts to Go with window.ryui.postMessage(text), which calls
OnMessage. Go runs script in the page with ui.EvalJS, which returns
nothing, or ui.EvalJSResult, which calls back with the value as JSON:
the last statement's, or what a returned promise resolves to (null for
undefined), or the error it threw.
var web ui.Node
web = ui.WebView(ui.WebViewProps{
HTML: ui.Val(`<button onclick="ryui.postMessage('tapped')">Tap</button>`),
OnMessage: func(m string) {
ui.EvalJSResult(web, "document.title", func(v json.RawMessage, err error) {
fmt.Println(m, string(v), err)
})
},
})ui.WebCookies(url, done) gets the cookies web views have for a URL, as
a Cookie header: what the user signed in to in a page, for your own
requests.
examples/downloader uses all of this: a
browser with an address bar, a preload script, blocking and downloads.
Shapes
ui.Shape draws an outline, filled, stroked or both. Path makes the
outline for the shape's size; it runs again when the size changes, and
when signals it reads change. A shape takes the size its style gives it
(a width and height, an aspect ratio, Grow); with none, it's empty.
ui.Shape(ui.ShapeProps{
Path: func(w, h float32) *ui.Path {
return new(ui.Path).MoveTo(w/2, 0).LineTo(w, h).LineTo(0, h).Close()
},
Fill: ui.Orange,
Style: ui.Style{Width: ui.Pt(48), Height: ui.Pt(48)},
})A ui.Path is in points from the shape's top left. Its methods add to
it and return it, to chain: MoveTo, LineTo, QuadTo, CubicTo,
Arc (angles in radians, clockwise from the x axis), Close, and whole
figures, Rect, RoundedRect and Ellipse.
Stroke and LineWidth (default 1) draw the outline, with a LineCap
(CapButt, CapRound, CapSquare) and a LineJoin (JoinMiter,
JoinRound, JoinBevel). TrimStart and TrimEnd draw part of it,
from 0 to 1: a progress ring, a line that draws itself. Changing them in
ui.Animate animates them (see Motion).
Dash dashes the stroke: lengths in points, drawn and skipped in turn
([]float32{6, 4}; an odd count repeats), as SwiftUI's
StrokeStyle(dash:) does. DashPhase is how far into the pattern it
starts. examples/usage draws a forecast with
one.
func ring(w, h float32) *ui.Path {
r := min(w, h)/2 - 4
return new(ui.Path).Arc(w/2, h/2, r, -math.Pi/2, 3*math.Pi/2, true)
}
func progressRing(done signal.Accessor[float32]) ui.Node {
box := ui.Style{Absolute: true, Left: ui.Pt(0), Top: ui.Pt(0), Width: ui.Pct(100), Height: ui.Pct(100)}
return ui.View(ui.ViewProps{Label: "Progress", Style: ui.Style{Width: ui.Pt(72), Height: ui.Pt(72)}},
ui.Shape(ui.ShapeProps{Path: ring, Stroke: ui.SecondaryBackground, LineWidth: 8, Style: box}),
ui.Shape(ui.ShapeProps{Path: ring, Stroke: ui.Accent, LineWidth: 8, LineCap: ui.CapRound,
TrimEnd: done, Style: box}),
)
}Containers
ui.View holds other views. ui.Column and ui.Row are views that
stack their children top to bottom and leading to trailing. ui.SafeArea
is a column padded by the notch, the system bars and the keyboard. How
they size and place what's in them is in Layout.
A view with no look, events or accessibility props makes no native view of its own: its children go in the nearest one that does. So nesting rows and columns costs nothing.
open := signal.New(false)
return ui.View(ui.ViewProps{
OnPress: func() { open.Set(!open.Peek()) },
OnLongPress: func() { ui.Haptic(ui.HapticMedium) },
Label: "Ada, 2 unread",
Style: ui.Style{Direction: ui.RowDir, Gap: 12, Padding: ui.All(16), Background: ui.SecondaryBackground, CornerRadius: 12},
},
ui.Symbol(ui.IconPerson.Filled()),
ui.Column(ui.Style{Grow: 1, Gap: 2},
ui.StaticText("Ada", ui.Style{Font: ui.Headline}),
ui.StaticText("2 unread", ui.Style{Color: ui.SecondaryLabel}),
),
)ViewProps has:
OnPressandOnLongPress.- Gestures, with the platform's recognizers:
OnPan,OnPinch,OnRotate(each given aui.Gesture) andOnSwipe(aui.SwipeDirection). See Motion. OnLayout, called with the view'sui.Frameonce it's laid out and each time that changes.- Accessibility:
Label,Hint,Role,Decorative,ActionsandOnAdjust. See Accessibility.
Scroll
ui.Scroll scrolls its children along one axis. Give it a size, or
Grow, so it has something to scroll within. Style is the scroll
view's box; ContentStyle is the box its children are stacked in.
down := signal.New(false)
var scroll ui.Node
scroll = ui.Scroll(ui.ScrollProps{
Style: ui.Style{Grow: 1},
OnScroll: func(_, y float32) { down.Set(y > 300) },
}, ui.SafeArea(ui.Style{Padding: ui.All(20), Gap: 16}, content...))
return ui.View(ui.ViewProps{Style: ui.Style{Grow: 1}}, scroll,
ui.Show(down, func() ui.Node {
return ui.View(ui.ViewProps{Style: ui.Style{Absolute: true, Right: ui.Pt(20), Bottom: ui.Pt(20)}},
ui.Button(ui.ButtonProps{Icon: ui.IconUp, Label: "Back to top", Variant: ui.ButtonFilled,
OnPress: func() { ui.ScrollTo(scroll, 0, 0, true) }}))
}))OnScrollis called as it scrolls, with its content offset in points.ui.ScrollTo(scroll, x, y, animated)scrolls to an offset (past the end is the end);ui.ScrollToEnd(scroll, animated)to its bottom, or a horizontal one's trailing edge. Both work on aListtoo.Horizontalscrolls sideways:
ui.Scroll(ui.ScrollProps{Horizontal: true, ContentStyle: ui.Style{Gap: 8, Padding: ui.Symmetric(0, 16)}}, kids...)Pull to refresh. OnRefresh turns it on (UIRefreshControl,
SwipeRefreshLayout). Set Refreshing true while you refresh; the
spinner hides when it turns false. Without Refreshing, it hides as
soon as OnRefresh returns.
refreshing := signal.New(false)
return ui.Scroll(ui.ScrollProps{
Style: ui.Style{Grow: 1},
Refreshing: refreshing,
OnRefresh: func() {
refreshing.Set(true)
go func() {
time.Sleep(time.Second) // fetch
ui.Post(func() { refreshing.Set(false) })
}()
},
}, ui.StaticText("Pull down"))Lists
ui.List shows a row per item in the platform's recycling list
(UICollectionView, RecyclerView). Rows are made only for the cells
on screen; a cell scrolled away is reused for another item, and only the
props that differ are updated. examples/list10k
has ten thousand rows.
messages := signal.NewFunc(fetch(0, 30), nil)
return ui.List(ui.ListProps{}, messages, func(m message) int { return m.ID }, row)func row(m signal.Accessor[message], _ signal.Accessor[int]) ui.Node {
return ui.Column(ui.Style{Gap: 2, Padding: ui.Symmetric(12, 20)},
ui.Text(signal.Func(func() string { return m.Get().From }), ui.Style{Font: ui.Headline}),
ui.Text(signal.Func(func() string { return m.Get().Subject }), ui.Style{Color: ui.SecondaryLabel}),
)
}It takes the items, a key for each, and a function that builds a row
from an item and its index, both accessors. Keys say which item is
which from one list to the next: a changed item updates its row, a moved
one moves it, and the platform animates the difference. Read the item in
a signal.Func, as above, so a reused row follows its new item.
A list fills the space it's given: with no height and no Grow, it
grows. It draws the platform's separators between rows.
ListProps has:
SwipeActions, the actions of rowi, revealed by swiping it. On iOS they are its trailing actions, a destructive one red, the first run by a full swipe. On Android a full swipe runs the first, over its color and icon, as Material's swipe to dismiss. Give the row the same as accessibilityActions, for screen readers.OnEndReached, called when the last rows are about to show, once for each length the list has: load more there.OnRefreshandRefreshing, pull to refresh as on aScroll.OnScroll, as on aScroll.
messages := signal.NewFunc(fetch(0, 30), nil)
remove := func(i int) {
messages.Update(func(ms []message) []message { return slices.Delete(slices.Clone(ms), i, i+1) })
}
list := ui.List(ui.ListProps{
SwipeActions: func(i int) []ui.Action {
return []ui.Action{
{Title: "Delete", Icon: ui.IconTrash, Role: ui.ActionDestructive, OnPress: func() { remove(i) }},
{Title: "Archive", Icon: "archivebox", OnPress: func() { remove(i) }},
}
},
OnEndReached: func() {
ms := messages.Peek()
messages.Set(append(slices.Clone(ms), fetch(len(ms), 30)...))
},
}, messages, func(m message) int { return m.ID }, row)ui.ScrollToItem(list, index, animated) scrolls a list so an item
shows; ScrollTo and ScrollToEnd work too.
Grids
Columns makes a list a grid (a LazyVGrid, a GridLayoutManager):
items fill rows of that many, each as wide as the others, Gap apart
across and down. A row is as tall as its tallest item. A grid has no
separators and no swipe actions.
ui.List(ui.ListProps{Columns: 3, Gap: 2}, ui.Val(ids), func(id int) int { return id },
func(id signal.Accessor[int], _ signal.Accessor[int]) ui.Node {
return ui.Image(ui.ImageProps{
Source: signal.Func(func() string { return fmt.Sprintf("https://picsum.photos/id/%d/300/300", id.Get()) }),
Mode: ui.ImageFill,
Style: ui.Style{AspectRatio: 1},
})
})List or For in a Scroll
ui.For makes a row per item too, keyed the same way, but it makes all
of them, as plain views among their siblings. Use it in a Scroll for
a short list that's part of a page: a few tags, a form's rows, a
section under a header.
ui.Scroll(ui.ScrollProps{Style: ui.Style{Grow: 1}, ContentStyle: ui.Style{Padding: ui.All(20), Gap: 8}},
ui.StaticText("Tags", ui.Style{Font: ui.Headline}),
ui.For(tags, func(t string) string { return t }, func(t signal.Accessor[string], _ signal.Accessor[int]) ui.Node {
return ui.Text(t)
}),
)Use List when the items are many or unbounded (a feed, an inbox,
search results, photos), or you want swipe actions, separators, loading
more at the end, or a grid. A List scrolls by itself: don't put it in
a Scroll.
Your own views
When a platform has a widget RyUI doesn't, write it in Swift and Kotlin
and show it with ui.Native. See Native code.