Introduction
Buoyant makes it easy to create flexible, dynamic, and (eventually) interactive UIs
on embedded systems. It is designed to be used with the embedded-graphics
crate, but
can be adapted to other rendering targets.
The features and API language are heavily influenced by SwiftUI. If you're already familiar with SwiftUI, you should feel right at home with Buoyant. If you aren't, don't worry.
Why create this?
The vast majority of my frontend experience is with SwiftUI, and I just want to use it for embedded. Despite what Apple would like you to think, Swift isn't all that great for embedded, so here we are doing it in Rust.
The well known std
Rust UI crates rely heavily on dynamic allocation, making them unsuitable
for porting to embedded.
On the embedded side, at least as of the time of writing, there weren't any other solutions
I found very satisfying. I'm not really interested in buying into the Slint ecosystem,
and aside from that, you'd essentially be stuck manually placing elements with
embedded-graphics
. Not fun at all.
This is my attempt to fill that need, and at least so far, it's been far more successful than I imagined. While Buoyant is still very young and I still feel new to Rust, Buoyant is already capable of building fairly complex UIs in SwiftUI's declarative style. Animations are not only possible, but quite easy to add.
Quickstart
Embedded graphics simulator
To run examples, you'll need to follow the instructions in the embedded-graphics-simulator README to install sdl2.
Add dependencies
# Cargo.toml
[dependencies]
buoyant = "0.4"
embedded-graphics = "0.8"
embedded-graphics-simulator = "0.7.0"
Hello World
Running this example will result in the words "Hello" (green) and "World" (yellow) separated by as much space as possible, with 20 pixels of padding around the edges.
Here is the full example, which will be picked apart in the following sections:
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout, render::{EmbeddedGraphicsRender as _, EmbeddedGraphicsView, Renderable as _}, view::{padding::Edges, HStack, LayoutExtensions as _, RenderExtensions as _, Spacer, Text}, }; use embedded_graphics::{mono_font::ascii::FONT_10X20, pixelcolor::Rgb888, prelude::*}; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::BLACK; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Hello World", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = hello_view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } fn hello_view() -> impl EmbeddedGraphicsView<Rgb888> { HStack::new(( Text::new("Hello", &FONT_10X20).foreground_color(Rgb888::GREEN), Spacer::default(), Text::new("World", &FONT_10X20).foreground_color(Rgb888::YELLOW), )) .padding(Edges::All, 20) }
Simulator Boilerplate
This is more or less the bare minimum to get a window up and running with the simulator.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout, render::{EmbeddedGraphicsRender as _, EmbeddedGraphicsView, Renderable as _}, view::{padding::Edges, HStack, LayoutExtensions as _, RenderExtensions as _, Spacer, Text}, }; use embedded_graphics::{mono_font::ascii::FONT_10X20, pixelcolor::Rgb888, prelude::*}; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::BLACK; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Hello World", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); // Render to display... window.show_static(&display); }
A window and a display framebuffer are created. display
importantly conforms to
embedded_graphics::DrawTarget<Color = Rgb888>
and is what you'll render content into.
The framebuffer is cleared to the background color, content is rendered, and finally the framebuffer is displayed.
Environment
#![allow(unused)] fn main() { extern crate buoyant; use buoyant::environment::DefaultEnvironment; let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); }
For static views with no animation, the environment is mostly irrelevant and the default environment will suffice.
If this view involved animation, the environment would be used to inject the time (as a duration), which you'd set every time you produce a new view.
View Body
#![allow(unused)] fn main() { extern crate buoyant; extern crate embedded_graphics; use buoyant::view::{padding::Edges, HStack, LayoutExtensions as _, RenderExtensions as _, Spacer, Text}; use buoyant::render::EmbeddedGraphicsView; use embedded_graphics::{mono_font::ascii::FONT_10X20, pixelcolor::Rgb888, prelude::*}; fn hello_view() -> impl EmbeddedGraphicsView<Rgb888> { HStack::new(( Text::new("Hello", &FONT_10X20).foreground_color(Rgb888::GREEN), Spacer::default(), Text::new("World", &FONT_10X20).foreground_color(Rgb888::YELLOW), )) .padding(Edges::All, 20) } }
The view body returned from this function simply encodes the structure and relationships between elements, along with holding references to resources like text and fonts. Note it has no notion of size or position.
This is an example of a component view. Unlike SwiftUI where views are types, Buoyant components
are functions (sometimes on types). You can take this view and compose it with other views
the same way built-in components like Text
are used.
Because embedded-graphics displays come in a wide variety of color spaces, component views
must also specify a color space. Often it's useful to alias this to make migration to another
screen easy, with e.g. type color_space = Rgb888
.
Layout
let layout = view.layout(&display.size().into(), &environment);
The layout call resolves the sizes of all the views. It is a bug to try to reuse the layout after mutating the view, and Buoyant may panic if you do so.
Render Tree
let render_tree = view.render_tree(&layout, origin, &environment);
The render tree is a minimal snapshot of the view. It holds a copy of the resolved positions,
sizes, colors, etc. of all the elements that are actually rendered to the screen.
Relational elements like Padding
, Frame
s, alignment, and so on have been stripped.
For rendering a static view, this feels like (and is) a lot of boilerplate from Buoyant. However, as you'll see later, having multiple snapshots allows incredibly powerful animation with next to no effort.
Rendering
render_tree.render(&mut display, &DEFAULT_COLOR, origin);
Here, the snapshot is finally rendered to the display buffer. A default color, similar to SwiftUI's foreground color, is passed in. This is used for elements that don't have a color set.
Building Views
This section is an introduction to building views with Buoyant. It covers the process of using Buoyant, and is not intended to be an exhaustive reference of available features. For that, refer to the Buoyant documentation on docs.rs.
Prerequisites
For all the examples in this section, it is assumed that you have installed the
embedded-graphics-simulator
requirements and have added the following dependencies to your Cargo.toml
:
[dependencies]
buoyant = "0.4"
embedded-graphics = "0.8"
embedded-graphics-simulator = "0.7.0"
The boilerplate from the quickstart is used to drive all the examples in this section. I've hidden it to keep the examples concise, but know you can still see it by clicking the eye icon in case you want to run the example locally.
Stacks
Adjacent layout
HStack
and VStack
are the primary tools you'll use to arrange views side-by-side in Buoyant.
Both stacks can contain a heterogeneous set of views and can be nested inside other stacks.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout as _, render::{EmbeddedGraphicsRender as _, Renderable as _}, }; use embedded_graphics::{pixelcolor::Rgb888, prelude::*}; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } use buoyant::view::shape::{Circle, Rectangle}; use buoyant::view::HStack; use buoyant::view::RenderExtensions as _; use buoyant::render::EmbeddedGraphicsView; fn view() -> impl EmbeddedGraphicsView<Rgb888> { HStack::new(( Circle.foreground_color(Rgb888::CSS_CORAL), Rectangle .corner_radius(25) .foreground_color(Rgb888::CSS_DARK_ORCHID), )) }
In this example, you can see HStack
fairly offers both views half the available width.
Rectangle
greedily takes all the offered space on both axes, while Circle
only takes
space greedily on the shorter axis to retain its square aspect ratio.
Arranging views on top of each other
ZStack
can be used to overlay views on top of each other. Like HStack
and VStack
,
it can contain a heterogeneous set of views.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout as _, render::{EmbeddedGraphicsRender as _, Renderable as _}, }; use embedded_graphics::{pixelcolor::Rgb888, prelude::*}; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } use buoyant::view::padding::Edges; use buoyant::view::shape::{Circle, Rectangle}; use buoyant::view::{LayoutExtensions as _, RenderExtensions as _}; use buoyant::view::ZStack; use buoyant::render::EmbeddedGraphicsView; fn view() -> impl EmbeddedGraphicsView<Rgb888> { ZStack::new(( Rectangle .corner_radius(50) .foreground_color(Rgb888::CSS_DARK_ORCHID), Circle.foreground_color(Rgb888::CSS_CORAL), Circle .foreground_color(Rgb888::CSS_GOLDENROD) .padding(Edges::All, 25), )) }
The .padding()
modifier is useful here to create space around the topmost yellow circle.
Combining Stacks
Stacks can be nested to create complex layouts.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout as _, render::{EmbeddedGraphicsRender as _, Renderable as _}, }; use embedded_graphics::{pixelcolor::Rgb888, prelude::*}; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } use buoyant::view::padding::Edges; use buoyant::view::shape::{Circle, Rectangle}; use buoyant::view::{LayoutExtensions as _, RenderExtensions as _}; use buoyant::view::{HStack, VStack, ZStack}; use buoyant::render::EmbeddedGraphicsView; fn view() -> impl EmbeddedGraphicsView<Rgb888> { HStack::new(( VStack::new(( Circle.foreground_color(Rgb888::CSS_GOLDENROD), Circle.foreground_color(Rgb888::CSS_GHOST_WHITE), )), ZStack::new(( Rectangle .corner_radius(50) .foreground_color(Rgb888::CSS_DARK_ORCHID), Rectangle .corner_radius(25) .foreground_color(Rgb888::CSS_CORAL) .padding(Edges::All, 25), )), )) }
Stack conformance to the necessary traits is macro-derived up to stacks of 10 views
Alignment
When arranging elements in stacks, placement ambiguity can arise when the child views differ in length along the axis opposite to the arrangement axis.
Vertical stacks can have horizontal alignment ambiguity, and horizontal stacks can have vertical alignment ambiguity.
In the previous example, the HStack
height resolved to the full window height
because Rectangle
takes height greedily. But the Circle
frame is shorter
so Buoyant needs to make a decision about where to place it. This ambiguity was
resolved using the default, Center
, but you can change it by calling
.with_alignment()
on the stack.
If you wanted to align the Circle
from the previous example to the top edge,
you could set VerticalAlignment::Top
.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout, render::{EmbeddedGraphicsRender as _, Renderable as _}, }; use embedded_graphics::{pixelcolor::Rgb888, prelude::*}; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } use buoyant::layout::VerticalAlignment; use buoyant::view::shape::{Circle, Rectangle}; use buoyant::view::HStack; use buoyant::view::RenderExtensions as _; use buoyant::render::EmbeddedGraphicsView; fn view() -> impl EmbeddedGraphicsView<Rgb888> { HStack::new(( Circle.foreground_color(Rgb888::CSS_CORAL), Rectangle .corner_radius(25) .foreground_color(Rgb888::CSS_DARK_ORCHID), )) .with_alignment(VerticalAlignment::Top) }
You'll see this ambiguity arise again with ZStack
and the frame modifiers.
A note on how SwiftUI alignment differs
If you're coming from SwiftUI, your understanding of alignment is most likely a bit wrong. This is probably fine, and conveniently, if you're wrong about SwiftUI you probably already have the correct understanding of alignment in Buoyant!
I think it's worth briefly explaining how SwiftUI alignment actually works and what that means for special alignments you can't build (yet) in Buoyant. Feel free to skip this if you're totally new to both and feeling lost.
Using HStack as an example, when you specify .bottom
vertical alignment in SwiftUI you are
not telling the HStack to align all the children to the bottom edge of the stack. You are
telling it to align the .bottom
alignment guides of all the children. Each child view
reports a set of named marks, called alignment guides, which the stack uses for alignment.
While generally you can assume .top
is placed at y = 0 and .bottom
is placed at y =
frame.height, views are free to place these guides wherever they want. The stack simply
lines up the mark across all the children. Custom marks are even preserved when nesting
views inside other modifiers.
This is especially useful when you have a horizontal stack containing text of multiple sizes. You can tell SwiftUI to align the text baselines, giving a much more visually appealing result.
Buoyant does not have this feature, but I recognize the utility it provides. For now, you can only align to the center and outer edges. Thinking about alignment in terms of "Top aligns views to the top" is correct for Buoyant.
Stack Spacing
Stack spacing is the first spacing you should reach for. It applies fixed spacing between child views.
By default, VStack
and HStack
place their child views with no spacing in between.
You can configure the spacing between child views using .with_spacing
.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout, render::{EmbeddedGraphicsRender as _, Renderable as _}, }; use embedded_graphics::{pixelcolor::Rgb888, prelude::*}; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } use buoyant::layout::HorizontalAlignment; use buoyant::view::shape::{Capsule, Circle, Rectangle}; use buoyant::view::RenderExtensions as _; use buoyant::view::VStack; use buoyant::render::EmbeddedGraphicsView; fn view() -> impl EmbeddedGraphicsView<Rgb888> { VStack::new(( Circle.foreground_color(Rgb888::CSS_CORAL), Rectangle .corner_radius(25) .foreground_color(Rgb888::CSS_DARK_ORCHID), Capsule.foreground_color(Rgb888::CSS_GOLDENROD), )) .with_alignment(HorizontalAlignment::Trailing) .with_spacing(10) }
Creating Visual Hierarchy
Often, you'll want to create a hierarchy which visually separates primitive elements, components, and sections of components. You can achieve this by nesting stacks and using incrementally larger values for each class of separation.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout, render::{EmbeddedGraphicsRender as _, Renderable as _}, }; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } use buoyant::layout::{HorizontalAlignment, VerticalAlignment}; use buoyant::view::padding::Edges; use buoyant::view::shape::Circle; use buoyant::view::{HStack, Text, VStack}; use buoyant::view::{LayoutExtensions as _, RenderExtensions as _}; use buoyant::render::EmbeddedGraphicsView; use embedded_graphics::{ mono_font::ascii::{FONT_7X13, FONT_9X15, FONT_9X15_BOLD}, pixelcolor::Rgb888, prelude::*, }; mod spacing { pub const ELEMENT: u16 = 6; pub const COMPONENT: u16 = 12; pub const SECTION: u16 = 18; } fn view() -> impl EmbeddedGraphicsView<Rgb888> { VStack::new(( VStack::new(( Text::new("Parents", &FONT_9X15_BOLD), contact_row(Rgb888::CSS_CORAL, "Alice", "Mother"), contact_row(Rgb888::CSS_DARK_ORCHID, "Bob", "Father"), )) .with_alignment(HorizontalAlignment::Leading) .with_spacing(spacing::COMPONENT), VStack::new(( Text::new("Siblings", &FONT_9X15_BOLD), contact_row(Rgb888::CSS_GOLDENROD, "Clyde", "Brother"), contact_row(Rgb888::CSS_SKY_BLUE, "Denise", "Sister"), )) .with_alignment(HorizontalAlignment::Leading) .with_spacing(spacing::COMPONENT), )) .with_alignment(HorizontalAlignment::Leading) .with_spacing(spacing::SECTION) .padding(Edges::Horizontal, spacing::COMPONENT) .padding(Edges::Vertical, spacing::SECTION) } fn contact_row<'a>( color: Rgb888, name: &'a str, relationship: &'a str, ) -> impl EmbeddedGraphicsView<Rgb888> + use<'a> { HStack::new(( Circle.foreground_color(color).frame().with_width(40), VStack::new(( Text::new(name, &FONT_9X15), Text::new(relationship, &FONT_7X13), )) .with_alignment(HorizontalAlignment::Leading) .with_spacing(spacing::ELEMENT) .foreground_color(Rgb888::WHITE), )) .with_alignment(VerticalAlignment::Top) .with_spacing(spacing::ELEMENT) }
The frames of Buoyant built-in views are always tight to the rendered content1. You should strive to maintain this property in your custom component views. Without it, you'll find it difficult to maintain well-organized hierarchies.
Watch out for alignment
Make sure you pay close attention to the alignment you set on each stack. Forgetting the
alignment on the components VStack
s would give you this weird result. Note how the frame
of the outer VStack
only actually extends to the end of the longest text.
While it's fairly obvious here that something is off, it can be much more subtle when you have text that spans the entire window width. Depending on where the text wraps, you may only sometimes see the problem.
Text rendered with embedded-graphics
monospace fonts break the "tight frames" rule.
If you look closely, you'll see that names which render entirely above the baseline
(no g, j, p, q, or y) appear to have weird extra spacing underneath that doesn't match the
other nearby element spacing. This is what you're trying to avoid by ensuring your frames
are tight to the rendered content.
Separating Views
Spacer
is used as a sort of shim to create flexible spacing between views in a stack.
Here, Spacer
is used to push the two Circle
s to either side.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout, render::{EmbeddedGraphicsRender as _, Renderable as _}, }; use embedded_graphics::{pixelcolor::Rgb888, prelude::*}; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } use buoyant::layout::HorizontalAlignment; use buoyant::view::shape::{Capsule, Circle, Rectangle}; use buoyant::view::RenderExtensions as _; use buoyant::view::{HStack, Spacer, VStack}; use buoyant::render::EmbeddedGraphicsView; fn view() -> impl EmbeddedGraphicsView<Rgb888> { VStack::new(( HStack::new(( Circle.foreground_color(Rgb888::CSS_CORAL), Spacer::default(), Circle.foreground_color(Rgb888::CSS_CORAL), )), Rectangle .corner_radius(25) .foreground_color(Rgb888::CSS_DARK_ORCHID), Capsule.foreground_color(Rgb888::CSS_GOLDENROD), )) .with_alignment(HorizontalAlignment::Trailing) // no effect! .with_spacing(10) }
Note that with this update, changes to the VStack
alignment no longer have any effect!
The Spacer
forces the HStack
to always take the full width offered by the VStack
,
meaning the VStack
child views will always have the same width. There is therefore no
ambiguity in the alignment of the VStack
children.
Mixed Alignment
Consider creating the following view with these requirements:
- The purple circle should be aligned to the left, while the orange and yellow circles should be aligned to the right.
- The layout should span the entire width of the window.
- The circles should all be roughly a third the height of the window, with a bit of spacing.
Using Spacer
to achieve alignment
You're probably thinking, "this feels like the perfect time to use Spacer
!"
I'll briefly indulge this misconception.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout as _, render::{EmbeddedGraphicsRender as _, Renderable as _}, }; use embedded_graphics::{pixelcolor::Rgb888, prelude::*}; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } // No! use buoyant::layout::HorizontalAlignment; use buoyant::view::shape::Circle; use buoyant::view::RenderExtensions as _; use buoyant::view::{HStack, Spacer, VStack}; use buoyant::render::EmbeddedGraphicsView; fn view() -> impl EmbeddedGraphicsView<Rgb888> { VStack::new(( Circle.foreground_color(Rgb888::CSS_CORAL), HStack::new(( Circle.foreground_color(Rgb888::CSS_DARK_ORCHID), Spacer::default(), )), Circle.foreground_color(Rgb888::CSS_GOLDENROD), )) .with_alignment(HorizontalAlignment::Trailing) .with_spacing(10) }
The VStack
is set to align the trailing edges of its children simply because it's more
convenient given there are more child views that need to be aligned to the right.
On their own, the Circle
s would not result in the VStack
spanning the entire width,
so an extra HStack
is added with a Spacer
to make sure at least one child spans the
full width. It even serves a dual purpose of aligning the purple Circle
to the left side!
This does produce the desired result, but there's a better way to achieve it.
A note for SwiftUI developers
In SwiftUI, Spacers have a 10 point minimum length. While it's probably not an issue for our specific example, using the HStack+Spacer solution in SwiftUI without manually overriding the Spacer minimum length results in 10 points of space your content can never fill. Text wraps a bit early and you can't put your finger on why you feel a little sad.
This is such a common mistake in SwiftUI that Buoyant uses 0 as the default minimum Spacer
length. Now you can make this mistake and only I'll be sad.
Flexible Frames
A better way to get the same result is to use the flex_frame()
modifier, which is closely
related to the fixed .frame()
modifier used earlier to set the width of the Circle
in the contact_row
component.
Instead of setting fixed dimensions, flexible frames allow you to create a virtual frame with configurable minimum and maximum dimensions. Child views won't necessarily take all the space within this frame, so you can also specify horizontal and vertical alignment to place the child within the virtual frame.
Buoyant provides .with_infinite_max_width()
and .with_infinite_max_height()
for creating
virtual frames that are as wide or tall as possible. You can use this in combination with
a leading horizontal alignment of the circle inside the frame to achieve the same result
as the previous code.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout as _, render::{EmbeddedGraphicsRender, Renderable}, }; use embedded_graphics::{pixelcolor::Rgb888, prelude::*}; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } // Preferred use buoyant::layout::HorizontalAlignment; use buoyant::view::shape::Circle; use buoyant::view::VStack; use buoyant::view::{LayoutExtensions as _, RenderExtensions as _}; use buoyant::render::EmbeddedGraphicsView; fn view() -> impl EmbeddedGraphicsView<Rgb888> { VStack::new(( Circle.foreground_color(Rgb888::CSS_CORAL), Circle .foreground_color(Rgb888::CSS_DARK_ORCHID) .flex_frame() .with_infinite_max_width() .with_horizontal_alignment(HorizontalAlignment::Leading), Circle.foreground_color(Rgb888::CSS_GOLDENROD), )) .with_alignment(HorizontalAlignment::Trailing) .with_spacing(10) }
This is both faster for Buoyant to lay out and easier to read.
Preferred implementations
This behavior is so common, shortcuts exist to specify an infinite width or height with the alignment in one call.
Maximally wide views
#![allow(unused)] fn main() { extern crate buoyant; use buoyant::layout::HorizontalAlignment; use buoyant::view::LayoutExtensions as _; use buoyant::view::{shape::Circle, HStack, Spacer}; let content = Circle; // Avoid: HStack::new(( Spacer::default(), content, )) ; // Preferred: content .flex_frame() .with_infinite_max_width() .with_horizontal_alignment(HorizontalAlignment::Trailing) ; // Preferred, shortcut: content .flex_infinite_width(HorizontalAlignment::Trailing) ; }
Maximally tall views
#![allow(unused)] fn main() { extern crate buoyant; use buoyant::layout::VerticalAlignment; use buoyant::view::LayoutExtensions as _; use buoyant::view::{shape::Circle, VStack, Spacer}; let content = Circle; // Avoid: VStack::new(( content, Spacer::default(), )) ; // Preferred: content .flex_frame() .with_infinite_max_height() .with_vertical_alignment(VerticalAlignment::Top) ; // Preferred, shortcut: content .flex_infinite_height(VerticalAlignment::Top) ; }
Collections
In the Stack Spacing section, VStack
was used to create what looks
suspiciously like a list of identical rows. For this purpose, ForEach
is typically a
better choice. Use ForEach when you want to display a collection of like views.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout, render::{EmbeddedGraphicsRender, EmbeddedGraphicsView, Renderable}, }; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(&SWATCHES); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } mod spacing { pub const ELEMENT: u16 = 6; pub const COMPONENT: u16 = 12; } struct Swatch { name: &'static str, color: Rgb888, } use buoyant::layout::HorizontalAlignment; use buoyant::view::padding::Edges; use buoyant::view::{shape::RoundedRectangle, ForEach, HStack, Text}; use buoyant::view::{LayoutExtensions as _, RenderExtensions as _}; use embedded_graphics::{mono_font::ascii::FONT_9X15, pixelcolor::Rgb888, prelude::*}; static SWATCHES: [Swatch; 4] = [ Swatch { name: "Indigo", color: Rgb888::CSS_INDIGO, }, Swatch { name: "Indian Red", color: Rgb888::CSS_INDIAN_RED, }, Swatch { name: "Dark Orange", color: Rgb888::CSS_DARK_ORANGE, }, Swatch { name: "Mint Cream", color: Rgb888::CSS_MINT_CREAM, }, ]; fn view(swatches: &[Swatch]) -> impl EmbeddedGraphicsView<Rgb888> + use<'_> { ForEach::<10>::new(swatches, |swatch| { HStack::new(( RoundedRectangle::new(8) .foreground_color(swatch.color) .frame_sized(40, 40), Text::new(swatch.name, &FONT_9X15).foreground_color(Rgb888::WHITE), )) .with_spacing(spacing::ELEMENT) }) .with_alignment(HorizontalAlignment::Leading) .with_spacing(spacing::COMPONENT) .padding(Edges::All, spacing::COMPONENT) }
ForEach
requires you to provide a const
size, and will lay out and render up to that
many items. As with VStack
, the alignment and spacing of its subviews can be controlled.
#![allow(unused)] fn main() { extern crate buoyant; use buoyant::view::{shape::RoundedRectangle, ForEach}; let radii = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; ForEach::<10>::new(&radii, |radius| { RoundedRectangle::new(*radius) }); }
The provided closure will be called for each item in the collection to produce a view.
While the rows in this example are simple and mostly identical, conditional views can be used to create the illusion of heterogeneity.
Performance Considerations and Future Work
Diff Animation
Buoyant does not currently support animating between orderings of a collection, and
will simply snap to the new ordering. This does not appear to be a difficult problem to
solve and the API of ForEach
is expected to change in future versions to accommodate it.
It is likely to be through the introduction of an additional trait bound on the slice values.
Offscreen Elements
Laying out large collections can be expensive, especially when the collection is only
partially onscreen. Ideally, Buoyant would attempt to guess at the size of the offscreen
elements and reserve the N
slots for items that are actually visible. Expect to see this
behavior eventually implemented, but there is a lot of core functionality that must be
built first.
Conditional Views
If you try something like this:
#![allow(unused)] fn main() { extern crate buoyant; extern crate embedded_graphics; use buoyant::render::EmbeddedGraphicsView; use buoyant::view::{Text, shape::Rectangle}; use embedded_graphics::{mono_font::ascii::FONT_9X15, pixelcolor::Rgb888, prelude::*}; fn view(is_redacted: bool) -> impl EmbeddedGraphicsView<Rgb888> { if is_redacted { Rectangle } else { Text::new("This is visible!", &FONT_9X15) } } }
You'll of course get an error telling you that the types returned from each branch don't match.
Buoyant provides two macros for creating content conditionally.
Conditional Views with if_view!
The if_view!
macro allows you to write views as if you were writing a plain if
statement.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout, render::{EmbeddedGraphicsRender, EmbeddedGraphicsView, Renderable}, }; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } use buoyant::if_view; use buoyant::view::{padding::Edges, shape::RoundedRectangle, LayoutExtensions as _, Text, VStack}; use embedded_graphics::{mono_font::ascii::FONT_9X15, pixelcolor::Rgb888, prelude::*}; fn secret_message(message: &str, is_redacted: bool) -> impl EmbeddedGraphicsView<Rgb888> + use<'_> { if_view!((is_redacted) { RoundedRectangle::new(4) .frame() .with_width(9 * message.len() as u16) // yeah yeah ignoring UTF8 .with_height(15) } else { Text::new(message, &FONT_9X15) }) } fn view() -> impl EmbeddedGraphicsView<Rgb888> { VStack::new(( secret_message("Top secret message", true), secret_message("Hi Mom!", false), secret_message("hunter12", true), secret_message("Cats are cool", false), )) .with_spacing(10) .with_alignment(buoyant::layout::HorizontalAlignment::Leading) .padding(Edges::All, 10) }
Variable Binding with match_view!
The match_view!
macro is a more powerful version of if_view!
that allows you to bind
variables in the match arms.
extern crate buoyant; extern crate embedded_graphics; extern crate embedded_graphics_simulator; use buoyant::{ environment::DefaultEnvironment, layout::Layout, render::{EmbeddedGraphicsRender, EmbeddedGraphicsView, Renderable}, view::EmptyView, }; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY; const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE; fn main() { let mut window = Window::new("Example", &OutputSettings::default()); let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320)); display.clear(BACKGROUND_COLOR).unwrap(); let environment = DefaultEnvironment::default(); let origin = buoyant::primitives::Point::zero(); let view = view(); let layout = view.layout(&display.size().into(), &environment); let render_tree = view.render_tree(&layout, origin, &environment); render_tree.render(&mut display, &DEFAULT_COLOR, origin); window.show_static(&display); } use buoyant::match_view; use buoyant::view::shape::{Rectangle, RoundedRectangle}; use buoyant::view::{padding::Edges, LayoutExtensions as _, RenderExtensions as _, VStack}; use embedded_graphics::{pixelcolor::Rgb888, prelude::*}; #[derive(Debug, Clone, Copy)] enum Shape { Rectangle, RoundedRect(u16), None, } fn shape(shape: Shape) -> impl EmbeddedGraphicsView<Rgb888> { match_view!(shape => { Shape::Rectangle => { Rectangle }, Shape::RoundedRect(radius) => { RoundedRectangle::new(radius) }, Shape::None => { EmptyView } }) } fn view() -> impl EmbeddedGraphicsView<Rgb888> { VStack::new(( shape(Shape::Rectangle) .foreground_color(Rgb888::CSS_PALE_GREEN), shape(Shape::RoundedRect(10)) .foreground_color(Rgb888::CSS_MEDIUM_ORCHID), shape(Shape::None) .foreground_color(Rgb888::WHITE), shape(Shape::RoundedRect(30)) .foreground_color(Rgb888::CSS_INDIAN_RED), )) .with_spacing(10) .padding(Edges::All, 10) }
Maintaining Consistent Spacing with EmptyView
Notice how despite returning a view for the Shape::None
variant above, the correct spacing
remains between its neighbors. EmptyView
is useful when you must return a view, but
don't want anything to be rendered and don't want to disrupt stack spacing.
Not all modifiers will transfer this spacing behavior when applied to an
EmptyView
.
When an if_view!
does not specify an else, EmptyView
is implied for the else branch.
#![allow(unused)] fn main() { extern crate buoyant; extern crate embedded_graphics; use buoyant::render::EmbeddedGraphicsView; use embedded_graphics::pixelcolor::Rgb888; use embedded_graphics::mono_font::ascii::FONT_9X15; use buoyant::view::{Text, shape::Rectangle}; use buoyant::if_view; /// A rectangle if not hidden, otherwise implicit `EmptyView` fn maybe_rectangle(hidden: bool) -> impl EmbeddedGraphicsView<Rgb888> { if_view!((!hidden) { Rectangle }) } }