SwipeBack
Pull a screen away from the left edge to go back, with the previous one peeking behind; a Back button for everyone else.
Demo
Invoice 2041
Invoice 2041
Site survey, two days
Total: PHP 48,500.00
Older line 1
Older line 2
Older line 3
Older line 4
Older line 5
Older line 6
Older line 7
Older line 8
Older line 9
Older line 10
Older line 11
Older line 12
Older line 13
Older line 14
Older line 15
Older line 16
Older line 17
Older line 18
Older line 19
Older line 20
Older line 21
Older line 22
Older line 23
Older line 24
Older line 25
Older line 26
Older line 27
Older line 28
Older line 29
Older line 30
Older line 31
Older line 32
Older line 33
Older line 34
Older line 35
Older line 36
Older line 37
Older line 38
Older line 39
Older line 40
Older line 41
Older line 42
Older line 43
Older line 44
Went back 0 times, now 0% across
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/swipe_back.qweimport ui/theme.qwe use theme_cssuse ui/swipe_back.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
let swipe_back_demo_names = ["Inbox", "Ana Reyes", "Invoice 2041"]let swipe_back_demo_lines = [ ["Ana Reyes: the invoice is attached", "Ben Okafor: Friday's call moved", "Carla Lim: photos from the site"], ["The invoice is attached.", "It is due on the 14th, in pesos.", "Tell me if the address has changed."], ["Invoice 2041", "Site survey, two days", "Total: PHP 48,500.00"],]view SwipeBackDemoScreen(name: str, lines: list) = <div style="padding: 8px 16px"> <p class="demo-note">{name}</p> {for l in lines => <p style="margin: 12px 0; padding-bottom: 12px; border-bottom: 1px solid var(--ui-border)">{l}</p>} {for i in range(1, 45) => <p style="margin: 18px 0; color: var(--ui-muted)">Older line {i}</p>} </div>component SwipeBackDemoLive() { state depth = 2 state backs = 0 state across = 0 let name = swipe_back_demo_names[depth] let before = depth > 0 ? swipe_back_demo_names[depth - 1] : "" <div.demo-stack> <div.demo-tablet> <SwipeBack title={name} back_label={`Back to ${before}`} back_text={before} enabled={depth > 0} behind={depth > 0 ? <div style="padding-top: 48px"><SwipeBackDemoScreen name={before} lines={swipe_back_demo_lines[depth - 1]}/></div> : null} on_back={() => { depth -= 1; backs += 1 }} on_progress={(p) => { across = round(p * 100) }}> <SwipeBackDemoScreen name={name} lines={swipe_back_demo_lines[depth]}/> </SwipeBack> </div> <div.demo-row> <p class="demo-note">Went back {backs} {backs == 1 ? "time" : "times"}, now {across}% across</p> <Button size="lg" variant="outline" on_click={() => { depth = 2 }}>Start again</Button> </div> </div>}view SwipeBackDemo() = <SwipeBackDemoLive/>Source
// SwipeBack: a screen a finger pulls away from the left edge to go back, as on an// iPhone. The screen follows the finger with the previous one peeking behind it; let// go early and it springs back, go far enough or flick and it finishes and goes back.// The Back button at its top does the same for keys and screen readers, as does// Alt + Left. In a right-to-left page it pulls from the right edge.//// import ui/swipe_back.qwe use SwipeBack//// component Thread(previous: str) {// state open = true// <SwipeBack title="Ana" back_label={`Back to ${previous}`} behind={<InboxScreen/>}// enabled={open} on_back={() => { open = false }}>// <Messages/>// </SwipeBack>// }//// children: the screen. It sits in a scroller of its own, which keeps moving up and// down from the edge too. title: shown in its bar. back_label: the button's name for// screen readers ("Back"). back_text: the words beside the button's arrow (none).// behind: the previous screen, as HTML, shown only during a swipe (nothing by// default, a plain background then). edge: how near the edge a swipe starts, in pixels// (24; 32 in a pane 600 px wide or more). enabled: false turns off the swipe and hides// the button, for the first screen. on_back(): called once it has gone back; make the// previous screen the current one there. on_progress(share): called during a swipe// with how far across it is, 0 to 1, and with 0 on a spring back; a function that sets// a state here runs on each move, so keep it light. The box takes the size of its// parent, which needs a height.//// In a pane as wide as a tablet's, half way across would be a long reach, so 200 px// is enough. A screen's left edge is the system's in two places: Safari on an iPhone// keeps it for its own Back, and Android's gesture navigation for its own, where the// Back button is the way. In Quillo's iOS app it reaches the page.import ui/theme.qwe use ui_clslet swipe_back_css = css`.ui-swipeback { position: relative; display: block; width: 100%; height: 100%; min-height: 0; overflow: hidden; overscroll-behavior-x: contain; touch-action: pan-y pinch-zoom; background: var(--ui-subtle); color: var(--ui-fg); font: 15px/1.5 var(--ui-font); container-type: inline-size;}.ui-swipeback-behind, .ui-swipeback-front { position: absolute; inset: 0; }.ui-swipeback-behind { visibility: hidden; overflow: hidden; background: var(--ui-bg); }.ui-swipeback-on .ui-swipeback-behind { visibility: visible; will-change: transform; }.ui-swipeback-scrim { position: absolute; inset: 0; opacity: 0; background: var(--ui-overlay); pointer-events: none; }.ui-swipeback-front { display: flex; flex-direction: column; background: var(--ui-bg); }.ui-swipeback-on .ui-swipeback-front { will-change: transform; box-shadow: var(--ui-shadow-lg); user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }.ui-swipeback-settling .ui-swipeback-front, .ui-swipeback-settling .ui-swipeback-behind { transition: transform .26s cubic-bezier(.2, .8, .2, 1); }.ui-swipeback-settling .ui-swipeback-scrim { transition: opacity .26s cubic-bezier(.2, .8, .2, 1); }.ui-swipeback-bar { display: flex; flex: none; align-items: center; gap: 2px; min-height: 48px; padding: env(safe-area-inset-top) max(4px, env(safe-area-inset-right)) 0 max(4px, env(safe-area-inset-left)); border-bottom: 1px solid var(--ui-border); background: var(--ui-surface);}.ui-swipeback-title { flex: 1; min-width: 0; margin: 0; overflow: hidden; font-size: 17px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }.ui-swipeback-back { display: inline-flex; flex: none; align-items: center; min-width: 44px; height: 44px; padding: 0 8px 0 4px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: inherit; cursor: pointer;}.ui-swipeback-back svg { width: 26px; height: 26px; }.ui-swipeback-back:dir(rtl) svg { transform: scaleX(-1); }.ui-swipeback-back:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-swipeback-back:hover { background: var(--ui-subtle); } }/* pan-y: the page keeps up and down; what goes sideways comes to the handlers here. Said again on the scroller, as it stops counting at one. */.ui-swipeback-body { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; touch-action: pan-y pinch-zoom; }@media (prefers-reduced-motion: reduce) { .ui-swipeback-settling .ui-swipeback-front, .ui-swipeback-settling .ui-swipeback-behind, .ui-swipeback-settling .ui-swipeback-scrim { transition: none; }}`// How far is far enough: half the screen, or 200 px where that would be a long reach.fn swipe_back_reach(width: num) -> num = width * 0.5 < 200 ? width * 0.5 : 200// Speed at release in px/ms, from the last sample kept. A finger that stopped for a// moment before lifting has no speed, whatever it did before.fn swipe_back_speed(from_x: num, from_t: num, x: num, t: num, now: num) -> num { if now - t > 90 || t <= from_t { return 0 } (x - from_x) / (t - from_t)}// Does a release finish the swipe? dx is how far, across a screen so wide, at speed.// Coming back towards the edge cancels, however far along; a flick finishes from// anywhere past the first 24 px.fn swipe_back_finishes(dx: num, width: num, speed: num) -> bool { if dx <= 0 || speed < -0.1 { return false } dx >= swipe_back_reach(width) || (speed >= 0.4 && dx >= 24)}// How much of the way across, 0 to 1, kept in that range for any input.fn swipe_back_share(dx: num, width: num) -> num { if width <= 0 || dx <= 0 { return 0 } dx >= width ? 1 : dx / width}// What the swipe does to the two screens, at a share of the way.fn swipe_back_paint(front, behind, scrim, share: num, width: num, sign: num) { let peek = (width * 0.3 < 120 ? width * 0.3 : 120) * (1 - share) front.style.transform = `translate3d(${share * width * sign}px, 0, 0)` behind.style.transform = `translate3d(${-peek * sign}px, 0, 0)` scrim.style.opacity = `${0.6 * (1 - share)}`}fn swipe_back_still(win) -> bool = win.matchMedia("(prefers-reduced-motion: reduce)").matchesfn swipe_back_clear(front, behind, scrim) { front.style.transform = "" behind.style.transform = "" scrim.style.opacity = ""}component SwipeBack(title: str = "", back_label: str = "Back", back_text: str? = null, behind = null, edge: num = 24, enabled: bool = true, on_back = null, on_progress = null, class: str? = null, children) { style global swipe_back_css state win = null // the window, so a handler has nothing to wait for state box = null state front = null state under = null state scrim = null state g = null // the finger: { sx, sy, fx, ft, x, t, on, sign } state showing = false // the screen behind shows: from the lock until it settles state settling = null // "back" or "stay", while the transition runs let width = () => box.clientWidth let finish = () => { let done = settling if done != null { settling = null showing = false swipe_back_clear(front, under, scrim) if done == "back" { on_back && on_back() } } } // Quiet: no transition to wait for when motion is turned down or nothing moved. let settle = (how: str) => { let sign = g != null ? g.sign : 1 g = null settling = how showing = true if swipe_back_still(win) { finish() } else { swipe_back_paint(front, under, scrim, how == "back" ? 1 : 0, width(), sign) on_progress && on_progress(how == "back" ? 1 : 0) } } // The button's way: the same slide, from where the screen stands. let go_back = () => { if enabled && settling == null && g == null { let sign = win.getComputedStyle(box).direction == "rtl" ? -1 : 1 showing = true settling = "back" if swipe_back_still(win) { finish() } else { // From where it stands to the far side: a frame apart, or there is no transition. swipe_back_paint(front, under, scrim, 0, width(), sign) win.requestAnimationFrame(() => { swipe_back_paint(front, under, scrim, 1, width(), sign) }) } } } // The end of a transition may never come (a hidden page, a screen that left). if settling != null { every(420, finish) } <div class={ui_cls(["ui-swipeback", showing && "ui-swipeback-on", settling != null && "ui-swipeback-settling", class])} @mount={(el) => { win = js("window") box = el }} @pointerdown={(e) => { // A second finger: a pinch or a mistake, not a swipe. if g != null && !e.isPrimary { if g.on { settle("stay") } else { g = null } } if enabled && settling == null && g == null && e.isPrimary && e.button == 0 { let sign = win.getComputedStyle(box).direction == "rtl" ? -1 : 1 let r = box.getBoundingClientRect() let from_edge = sign > 0 ? e.clientX - r.left : r.right - e.clientX let reach = r.width >= 600 && edge < 32 ? 32 : edge if from_edge >= 0 && from_edge <= reach { g = { sx: e.clientX, sy: e.clientY, fx: e.clientX, ft: e.timeStamp, x: e.clientX, t: e.timeStamp, on: false, sign: sign, id: e.pointerId } } } }} @pointermove={(e) => { if g != null && e.pointerId == g.id { let dx = (e.clientX - g.sx) * g.sign let dy = e.clientY - g.sy if !g.on && abs(dx) + abs(dy) >= 10 { // Locked to one axis once: up and down is the scroller's. if dx <= abs(dy) { g = null } else { g = { ...g, on: true } showing = true box.setPointerCapture(e.pointerId) } } if g != null && g.on { let keep = e.timeStamp - g.t > 40 g = { ...g, fx: keep ? g.x : g.fx, ft: keep ? g.t : g.ft, x: e.clientX, t: e.timeStamp } let share = swipe_back_share(dx, width()) swipe_back_paint(front, under, scrim, share, width(), g.sign) on_progress && on_progress(share) } } }} @pointerup={(e) => { if g != null && e.pointerId == g.id { if g.on { let dx = (e.clientX - g.sx) * g.sign let speed = swipe_back_speed(g.fx, g.ft, g.x, g.t, e.timeStamp) * g.sign settle(swipe_back_finishes(dx, width(), speed) ? "back" : "stay") } else { g = null } } }} @pointercancel={(e) => { // The system took the gesture: nothing was meant, so nothing happens. if g != null && e.pointerId == g.id { if g.on { settle("stay") } else { g = null } } }} @keydown={(e) => { if e.altKey && e.key == "ArrowLeft" { e.preventDefault() go_back() } }}> <div.ui-swipeback-behind aria-hidden="true" inert="" @mount={(el) => { under = el }}> {behind} <div.ui-swipeback-scrim @mount={(el) => { scrim = el }}></div> </div> <div.ui-swipeback-front @mount={(el) => { front = el }} @transitionend={(e) => { if e.propertyName == "transform" && e.target.isSameNode(front) { finish() } }}> <div.ui-swipeback-bar> {enabled && <button.ui-swipeback-back type="button" aria-label={back_label} @click={go_back}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 5-7 7 7 7"/></svg> {back_text != null && <span>{back_text}</span>} </button>} <h2.ui-swipeback-title>{title}</h2> </div> <div.ui-swipeback-body tabindex="-1">{children}</div> </div> </div>}Related
Thread 0 posts
On SwipeBack: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.