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 }