Skip to content

Instantly share code, notes, and snippets.

@laughinghan
Last active July 28, 2023 06:35
Show Gist options
  • Save laughinghan/bffa95855d25f718d2b1ca0bf61c8e9e to your computer and use it in GitHub Desktop.
Save laughinghan/bffa95855d25f718d2b1ca0bf61c8e9e to your computer and use it in GitHub Desktop.

Minimalist Layout System

This super simple, fast, flexible layout system operates in a single pass (no reflow or constraint solving) and is probably <100 lines of non-comment, non-duplicate logic (there's a lot of duplicate logic between the top/left/right/bottom directional code that's not worth de-duplicating).

To layout PinLayout's Example 1:

pinlayout example 1

The code is much simpler than PinLayout's:

const logo      = new UIImage('/logo.png', 100, 100)
const segmented = new UISegmentedControl(['Intro', '1', '2'])
const text      = new UIText('Swift manual views layouting without auto layout, no magic, pure code …')
const separator = new UISeparator(1, 'blue')

return layout.pad_all(10)
.cut_top(sublayout =>
    sublayout.cut_left(logo, { align: 'top' }).pad_left(10)
    .cut_top(segmented).pad_top(10)
    .put(text)
)
.cut_top(separator)

This was inspired by RectCut, but instead of requiring upfront a fixed pixel size of cut to make, you can have the cut fit to the size of the content. It does this in one pass, without reflow, by "recursing" into the content and asking for its size. This system can also be thought of as a tree of nested columns and rows.

Just like PinLayout, this system is Just Code, so you still have full control & flexibility, you can do your own math on what size cut to make or put in if-statements wherever you want. This could even augment PinLayout by simplifying common usages but preserving an identical level of control.

(Some stuff that hasn't been implemented but easily could: min/max sizes on items/cuts; a way to add "overhang" to make a layout "stick out" past where it was cut somewhere higher in the tree, necessary for layouts like this; right-to-left or upwards layout)

function render(layout: Layout) {
const logo = new UIImage('/logo.png', 100, 100)
const segmented = new UISegmentedControl(['Intro', '1', '2'])
const text = new UIText('Swift manual views layouting without auto layout, no magic, pure code, full control ...')
const separator = new UISeparator(1, 'blue')
return layout.pad_all(10)
.cut_top(sublayout =>
sublayout.cut_left(logo, { align: 'top' }).pad_left(10)
.cut_top(segmented).pad_top(10)
.put(text)
)
.cut_top(separator)
/*
Alternative API without closures & without chaining:
layout.pad_all(10)
const sublayout = layout.cut_top()
sublayout.cut_left(logo, { align: 'top' })
sublayout.pad_left(10)
sublayout.cut_top(segmented)
sublayout.pad_top(10)
sublayout.put(text)
layout.cut_top(separator)
*/
}
const FONT_WIDTH = 10
const FONT_HEIGHT = 16
// the contract for render(): we tell you the available width & height (either or both
// of which may be 'fit-content'), and you tell us what size you'll be. Eg images will
// scale to fit inside the available dimensions, or text will determine its height based
// on the width.
// Known issue: assumes render() returns sizes less than given available sizes, and will
// crap out if it violates that
interface UIElement {
render(available_width: number | 'fit-content', available_height: number | 'fit-content'): Readonly<{ width: number, height: number }>
}
// images scale to fit inside the available dimensions, preserving their intrinsic
// aspect ratio; if both available width & height are 'fit-content', then the intrinsic
// size is used. Alternative: maybe should only scale to fit if available dimensions
// are smaller than the intrinsic size? So scaling factor will always be ≤1?
class UIImage implements UIElement {
constructor(readonly url: string, readonly width: number, readonly height: number) {}
render(avail_width: number | 'fit-content', avail_height: number | 'fit-content') {
// fit as big as it can while preserving aspect ratio
let width, height
if (avail_width === 'fit-content' && avail_height === 'fit-content') {
({ width, height } = this)
} else if (avail_width === 'fit-content'
|| this.height/this.width > (avail_height as number)/avail_width) {
height = avail_height as number
width = this.width*height/this.height
} else {
width = avail_width
height = this.height*width/this.width
}
return { width, height }
}
}
// fixed height, width expands to fit container
// Known issue: what if available width is less than min width?
class UISegmentedControl implements UIElement {
constructor(readonly items: readonly string[]) {}
render(avail_width: number | 'fit-content', avail_height: number | 'fit-content') {
if (avail_width === 'fit-content') {
return { width: this.items.join(' ').length*FONT_WIDTH, height: FONT_HEIGHT }
} else {
return { width: avail_width, height: FONT_HEIGHT }
}
}
}
// based on available width, determines the height needed.
// If width unconstrained, the whole text is put on one line.
// Should it have a default width instead?
// Known issue: doesn't do real text wrapping, instead estimates based on "typical
// area" of a character
class UIText implements UIElement {
constructor(readonly text: string) {}
render(avail_width: number | 'fit-content', avail_height: number | 'fit-content') {
// in leiu of real text wrapping, pretend that text is a 2D liquid with some amount of "2D volume"/area
const area = this.text.length*FONT_WIDTH*FONT_HEIGHT
let width, height
if (avail_width === 'fit-content' && avail_height === 'fit-content') {
height = FONT_HEIGHT
width = this.text.length*FONT_WIDTH
} else if (avail_width === 'fit-content') {
height = avail_height as number
width = area/height|0
} else {
width = avail_width
height = area/width|0
}
return { width, height }
}
}
// horizontal separator line
// Ben suggested this should be subsumed by a way to draw an arbitrary rectangle
class UISeparator implements UIElement {
constructor(readonly height: number, readonly color: string) {}
render(avail_width: number | 'fit-content', avail_height: number | 'fit-content') {
return { width: avail_width === 'fit-content' ? 20 : avail_width, height: this.height }
}
}
class Layout {
// If the initial available width or height is a fixed number, then the layout
// will always have that width or height and the available width and height will
// shrink as the layout is cut up to put things in.
// However if the initial available width or height is "fit-content" (meaning it's
// just large enough to fit its content), then the layout will always be fit-content,
// and instead the/ layout's width and height will grow as things are put in by
// cutting it up.
// Note that this is independent for width and height, so that's not 2 kinds
// of layouts, that's 4 kinds (fixed size, fixed width, fixed height, unconstrained).
// The top-level page is normally fixed width and unconstrained height.
constructor(
// dimensions of remaining available space in this layout:
// As described above, if a fixed size was initially provided, then the
// corresponding available dimension will shrink as the layout is cut up
// to put things in; but if it was initially 'fit-content', then the
// available dimension will always be 'fit-content'.
public available_width: number | 'fit-content',
public available_height: number | 'fit-content',
// top-left coordinates of remaining available space in this layout:
// As the layout is cut up to put things in it, these will move down and
// to the right.
private available_x = 0,
private available_y = 0,
// fake "rendering" of UI elements by "drawing" their rectangles, i.e. appending
// to this list the coordinates and sizes at which to draw each UI element
readonly rects: Array<{x: number, y: number, width: number, height: number, content: UIElement }> = []
) {
this.width = available_width === 'fit-content' ? 0 : available_width
this.height = available_height === 'fit-content' ? 0 : available_height
this.x = this.available_x
this.y = this.available_y
}
// the outer dimensions of this layout:
// As described above, if the initial available width or height was a fixed
// number, then this width/height will always be that value; but if the layout
// is fit-content along a dimension, then the width or height will grow as
// content is put into the layout by cutting it up.
width: number
height: number
// the initial top-left coordinates of the layout pane; never changes
private readonly x: number
private readonly y: number
// cut out padding along a side
pad_top(padding: number) {
this.available_y += padding
return this.pad_bottom(padding)
}
pad_bottom(padding: number) {
if (this.available_height === 'fit-content') {
this.height += padding
} else {
this.available_height -= padding
}
return this
}
pad_left(padding: number) {
this.available_x += padding
return this.pad_right(padding)
}
pad_right(padding: number) {
if (this.available_width === 'fit-content') {
this.width += padding
} else {
this.available_width -= padding
}
return this
}
pad_all(v_padding: number, h_padding?: number) {
h_padding ??= v_padding
this.available_x += h_padding
this.available_y += v_padding
return this.pad_bottom(2*v_padding).pad_right(2*h_padding)
}
// cut out a row at the top, put the given content in there
cut_top(content: UIElement | ((l: Layout) => Layout), { align }: { align: 'left' | 'center' | 'right' } = { align: 'center' }) {
// render the content, calculating the x/y/width/height and "drawing" it by
// adding it to the rects array
let rect
if (content instanceof Function) {
rect = content(new Layout(this.available_width, 'fit-content', this.available_x, this.available_y, this.rects))
} else {
rect = content.render(this.available_width, 'fit-content')
this.rects.push({ x: this.h_align(align, rect.width), y: this.available_y, ...rect, content })
}
// when cutting out a row at the top, the x-coord of the remaining available
// space doesn't change, but the y-coord grows
this.available_y += rect.height
// if the layout is fixed-width, that doesn't change;
// if the width is fit-content, then the width grows if this newly drawn
// rect would stick out the side of the layout
if (this.available_width === 'fit-content') {
this.width = Math.max(this.width, this.available_x - this.x + rect.width)
}
// if the layout is fixed-height, that doesn't change, but the remaining
// available width shrinks;
// if the height is fit-content, then the height grows if this newly drawn
// rect sticks out the bottom of the layout
if (this.available_height !== 'fit-content') {
this.available_height -= rect.height
} else {
this.height = Math.max(this.height, this.available_y - this.y)
}
return this
}
// cut out a row at the bottom, put the given content in there
cut_bottom(content: UIElement | ((l: Layout) => Layout), { align }: { align: 'left' | 'center' | 'right' } = { align: 'center' }) {
// can't put content at the bottom of a variable-height layout because we'd
// have to recalculate the y-coords as the height of the layout grows
if (this.available_height === 'fit-content') throw 'Can only cut-bottom of layouts with fixed height'
// render the content, calculating the x/y/width/height and "drawing" it by
// adding it to the rects array
let rect
if (content instanceof Function) {
rect = content(new Layout(this.available_width, 'fit-content', this.available_x, this.available_y, this.rects))
} else {
rect = content.render(this.available_width, 'fit-content')
const y = this.available_y + this.available_height - rect.height
this.rects.push({ x: this.h_align(align, rect.width), y, ...rect, content })
}
// cutting out a row at the bottom doesn't change the x or y-coord of the
// remaining available space, but does shrink the height
this.available_height -= rect.height
// if the layout is fixed-width, that doesn't change;
// if the width is fit-content, then the width grows if this newly drawn
// rect would stick out the side of the layout
if (this.available_width === 'fit-content') {
this.width = Math.max(this.width, this.available_x - this.x + rect.width)
}
return this
}
// cut out a column on the left, put the given content in there
cut_left(content: UIElement | ((l: Layout) => Layout), { align }: { align: 'top' | 'center' | 'bottom' } = { align: 'center' }) {
// render the content, calculating the x/y/width/height and "drawing" it by
// adding it to the rects array
let rect
if (content instanceof Function) {
rect = content(new Layout('fit-content', this.available_height, this.available_x, this.available_y, this.rects))
} else {
rect = content.render('fit-content', this.available_height)
this.rects.push({ x: this.available_x, y: this.v_align(align, rect.height), ...rect, content })
}
// when cutting out a column on the left, the y-coord of the remaining
// available space doesn't change, the x-coord grows
this.available_x += rect.width
// if the layout is fixed-width, that doesn't change, but the remaining
// available width shrinks;
// if the width is fit-content, then the width grows if this newly drawn
// rect sticks out the side of the layout
if (this.available_width !== 'fit-content') {
this.available_width -= rect.width
} else {
this.width = Math.max(this.width, this.available_x - this.x)
}
// if the layout is fixed-height, that doesn't change;
// if the height is fit-content, then the height grows if this newly drawn
// rect would stick out the bottom of the layout
if (this.available_height === 'fit-content') {
this.height = Math.max(this.height, this.available_y + rect.height - this.y)
}
return this
}
// cut out a column on the right, put the given content in there
cut_right(content: UIElement | ((l: Layout) => Layout), { align }: { align: 'top' | 'center' | 'bottom' } = { align: 'center' }) {
// can't put content on the right edge of a variable-width layout because we'd
// have to recalculate the x-coords as the width of the layout grows
if (this.available_width === 'fit-content') throw 'Can only cut right of layouts with fixed width'
// render the content, calculating the x/y/width/height and "drawing" it by
// adding it to the rects array
let rect
if (content instanceof Function) {
rect = content(new Layout('fit-content', this.available_height, this.available_x, this.available_y, this.rects))
} else {
rect = content.render('fit-content', this.available_height)
const x = this.available_x + this.available_width - rect.width
this.rects.push({ x, y: this.v_align(align, rect.height), ...rect, content })
}
// cutting out a column on the right doesn't change the x or y-coord of the
// remaining available space, but does shrink the width
this.available_width -= rect.width
// if the layout is fixed-height, that doesn't change;
// if the height is fit-content, then the height grows if this newly drawn
// rect would stick out the bottom of the layout
if (this.available_height === 'fit-content') {
this.height = Math.max(this.height, this.available_y + rect.height - this.y)
}
return this
}
// put the given item in the remaining space in the layout
put(content: UIElement, { align }: { align: 'top' | 'left' | 'center' | 'right' | 'bottom' | `${'top' | 'bottom'}-${'left' | 'right'}` } = { align: 'center' }) {
// parse the alignment specifier into h-align and v-align specifiers
let v_align: 'top' | 'center' | 'bottom', h_align: 'left' | 'center' | 'right'
if (align === 'top' || align === 'bottom') [v_align, h_align] = [align, 'center']
else if (align === 'left' || align === 'right') [v_align, h_align] = ['center', align]
else if (align === 'center') [v_align, h_align] = ['center', 'center']
else [v_align, h_align] = align.split('-') as ['top' | 'bottom', 'left' | 'right']
// render the content, calculating the x/y/width/height and "drawing" it by
// adding it to the rects array
const rect = content.render(this.available_width, this.available_height)
this.rects.push({
x: this.h_align(h_align, rect.width),
y: this.v_align(v_align, rect.height),
...rect,
content,
})
// if the layout is fixed-width, that doesn't change, but the remaining
// available width is used up;
// if the width is fit-content, then the width grows if this newly drawn
// rect sticks out the side of the layout
if (this.available_width !== 'fit-content') {
this.available_width = 0
} else {
this.width = Math.max(this.width, this.available_x - this.x + rect.width)
}
// if the layout is fixed-height, that doesn't change, but the remaining
// available height is used up;
// if the height is fit-content, then the height grows if this newly drawn
// rect sticks out the bottom of the layout
if (this.available_height !== 'fit-content') {
this.available_height = 0
} else {
this.height = Math.max(this.height, this.available_y - this.y + rect.height)
}
return this
}
// calculates the x-coord of a rect with given width & alignment in remaining available space
h_align(align: 'left' | 'center' | 'right', width: number) {
return align === 'left' || this.available_width === 'fit-content' ? this.available_x
: align === 'right' ? this.available_x + this.available_width - width
: /* center */ this.available_x + (this.available_width - width)/2 | 0
}
// calculates the y-coord of a rect with given height & alignment in remaining available space
v_align(align: 'top' | 'center' | 'bottom', height: number) {
return align === 'top' || this.available_height === 'fit-content' ? this.available_y
: align === 'bottom' ? this.available_y + this.available_height - height
: /* center */ this.available_y + (this.available_height - height)/2 | 0
}
}
// test:
const l = render(new Layout(800, 'fit-content'))
for (const rect of l.rects) {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = rect.x + 'px'
el.style.top = rect.y + 'px'
el.style.width = rect.width + 'px'
el.style.height = rect.height + 'px'
el.style.background = 'skyblue'
el.textContent = rect.content.constructor.name
document.body.appendChild(el)
}
// TypeScript won't assume this for ES5 output for some reason?
declare interface Function {
name: string
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment