Yes, async iterators are great! π
This repo demonstrates a real world implementation of pan & pinch gesture logic implemented with JavaScript async iterators inside a minimal React app.
β Featuring β
- async iterators as a state machine
- no additional libraries (except for React and TypeScript)
- lots of immutability and a little bit of JavaScript magic from async function * and yield
See the project live at https://jonelantha.github.io/async-iterators-gesture-demo/
npm install
npm run dev
flowchart RL
subgraph div["<div> (container)"]
img["<img>
(style & class)"]
end
imgStates["async function*<br /> imgStates"] -- yield<br />{ transform, animate }--> img
The <App>
React component is composed of an <img>
element inside a container <div>
element.
As the user interacts with the image, CSS Transforms are applied to the <img>
's style - this repositions the image relative to the container.
In certain situations an .animate
class is also added to the <img>
, for example if the <img>
should animate to the next transform position (the styles for the .animate
class includes a CSS Transition for the transform property).
The values for these attributes are supplied by the imgStates async iterator, see below.
The imgStates async iterator yields a series of { transform, animate }
values:
transform: DOMMatrix
- transform to apply to the<img>
(stored as a DOMMatrix)animate: boolean | undefined
- whether to add the.animate
class to the<img>
The imgStates async iterator yields its values straight from another async iterator, navCycle. navCycle is a finite async iterator; one complete iteration cycle of navCycle represents a single user interaction (see Navigation Cycles below). Once a cycle of navCycle has completed a new cycle will begin.
flowchart TB
subgraph imgStates[" "]
baseTransform["1#46; let baseTransform"] --> inputEvents
inputEvents["2#46; async function* inputEvents"] == yield<br />event ==> navCycle["3#46; async function* navCycle"]
navCycle -- "return<br />final transform" --> setBaseTransform["4#46; set new baseTransform"]
setBaseTransform -- "while (!signal.aborted)" --> baseTransform
navCycle == yield<br />{ transform, animate } ==> A@{ shape: f-circ, label: "" }
end
A == yield<br />{ transform, animate } ==> x[ ]:::invisible
S[ ]:::invisible
classDef invisible fill-opacity:0, stroke-opacity:0;
- baseTransform is the starting transform for the next navigation cycle, intitially set to the default matrix (no transform)
- inputEvents is an async iterator of events from the container element (the parent
<div/>
). Those events are PointerEvent, KeyboardEvent and WheelEvent - navCycle is an async iterator representing a single navigation cycle (see Navigation Cycles below). navCycle takes two parameters: the baseTransform from step 1 and the inputEvents async iterator from step 2. It yields a series of
{ transform, animate }
values, these values are in turn yielded by the enclosing imgStates async iterator (using yield *). - When done, navCycle returns the final transform of the navigation cycle. This return value is assigned to baseTransform and will be used as the starting transform for the next navigation cycle. A while loop returns to step 1 (unless the signal has been aborted).
One full iteration of the navCycle async iterator represents a single user interaction, for example:
- dragging with the mouse and releasing the mouse button
- a series of overlapping multi-touch gestures (the cycle ends when the last touch is released)
- a single press of an arrow key
The navCycle async iterator is divided into phases where each phase corresponds to a particular gesture, for example pan or pinch. Execution passes between the phases depending on pointer events:
---
title: "navCycle simplified - (touches/pointers only)"
---
flowchart LR
navCycle_source@{ shape: braces, label: "navCycle source" }
click navCycle_source "https://github.com/jonelantha/async-iterators-gesture-demo/blob/main/src/navCycle.ts"
START([Cycle Start]) --> initial_isPointerDown
subgraph initialPhase["initialPhase (no touches)"]
initial_source@{ shape: braces, label: "source" }
click initial_source "https://github.com/jonelantha/async-iterators-gesture-demo/blob/main/src/navCycle.ts#L15-L41"
initial_isPointerDown
initial_isPointerDown{pointerdown?} -- No --> initial_isPointerDown
end
subgraph panPhase["panPhase (one touch)"]
pan_isPointerUp{pointerup?} -- No --> pan_isPointerDown
pan_isPointerDown{pointerdown?} -- No --> pan_isPointerUp
pan_source@{ shape: braces, label: "source" }
click pan_source "https://github.com/jonelantha/async-iterators-gesture-demo/blob/main/src/navCycle.ts#L43-L95"
end
subgraph pinchPhase["pinch phase (two touches)"]
pinch_isPointerUp{pointerup?}
pinch_isPointerUp -- No --> pinch_isPointerUp
pinch_source@{ shape: braces, label: "source" }
click pinch_source "https://github.com/jonelantha/async-iterators-gesture-demo/blob/main/src/navCycle.ts#L97-L136"
end
END([Cycle End])
initial_isPointerDown -- Yes ----> pan_isPointerUp
pan_isPointerUp -- Yes ----> END
pan_isPointerDown -- Yes --> pinch_isPointerUp
pinch_isPointerUp -- Yes ----> pan_isPointerUp
- initialPhase - waiting for the first event [source]
- can transfer to the panPhase if a pointer goes down
- can process a KeyboardEvent or a WheelEvent and end iteration
- panPhase - the user has one pointer down and is performing a pan (drag) gesture [source]
- can transfer to pinchPhase if a second pointer becomes down
- can end iteration if the current pointer is released
- pinchPhase - the user has two pointers down and is performing a pinch gesture [source]
- can transfer back to a panPhase if one pointer is released
Pressing Esc
in the middle of a cycle cancels the gesture and makes the image slide back to the previous location (for example the user could press Esc
to abort a pan operation)
Implementation of aborting:
- User presses
Esc
- A KeyboardEvent for
Esc
is yielded from the inputEvents async iterator and processed by the navCycle async iterator - If execution is currently inside a gesture phase then navCycle will throw an Error and no more processing of the cycle occurs [source]
- The Error is then caught by the top level of the navCycle iterator [source]
- navCycle then yields
{ transform: baseTransform, animate: true }
which causes the<img />
element to slide back to the initial position of the cycle [source] - A new cycle is started
Blog article coming soon...