UIKit's UIViews in JavaScript, Part 2: Design and Implementation
This is a second part of a 2-part series, in which I describe a new view framework I developed while working on an experimental React concurrent mode profiler. In part 1, I discussed why implementing a view abstraction was necessary. In this part, we'll discuss what this framework looks like. I encourage you to read part 1 if you haven't!
Much of what we'll discuss in this post can be found in the pull request that implements the initial design. However, the PR requires some context to understand and does not include changes made to the architecture since we landed that PR. In other words, star the repository, but read on!
Design
At a high level, our view framework is the view layer within the model-view-controller (MVC) architectural pattern:
- Model: Our profiler has a
ReactProfilerData
object that contains the performance profile data to be rendered. - Controller: Its responsibilities include creating the view hierarchy, feeding data and user interactions into views, and triggering canvas drawing. In our profiler, the
CanvasPage
React component acts as the controller layer. It draws to the canvas during React's commit phase to keep the canvas in sync with DOM updates. - View: Our view framework is this layer!
Components
View
classes
First, the view framework is mainly comprised of the View
class and its subclasses:
View
: this is a base class that allView
s inherit from.- Views that manipulate their subviews in response to user interactions. These include
HorizontalPanAndZoomView
,VerticalScrollView
andResizableSplitView
. You can compose their behavior by nesting them. - Views that focus on displaying content and reacting to user interactions on them. These are application specific. Our profiler defines a
View
subclass for every major section of the app, e.g.TimeAxisMarkersView
,ReactEventsView
, andFlamechartView
.
Layouter
functions
Second, View
subclasses can optionally lay out its subviews with composable Layouter
functions. Layouters allow you to easily implement reusable view layouts. Want a vertical stack of views, where all the views are right-aligned, and where the second-last view grows to fill the superview? Knock yourself out. You get the flexibility of UIKit's Auto Layout without its complexity.
type LayoutInfo = {|view: View, frame: Rect|};
type Layout = LayoutInfo[];
type Layouter = (existingLayout: Layout, containingFrame: Rect) => Layout;
These layouters are more easily understood in code, but here's a sample of some of them:
alignToContainerXLayout
: aligns all subviews'origin.x
coordinate to the superview'sorigin.x
.desiredHeightLayout
: sets all views' heights to those returned by theirdesiredSize
method. This allows us to size views to fit their content.verticallyStackedLayout
: stacks all the subviews in a vertical stack.lastViewTakesUpRemainingSpaceLayout
: if the last view does not take up the remaining vertical space in the superview, this layout forces that view to take up that remaining space. This works best when composed withverticallyStackedLayout
.
Interaction
objects
Third, we have a React hook, useCanvasInteraction
. This listens to DOM events and converts them into view framework interactions. More about this in the interaction handling section below.
Surface
Last, a Surface
instance hosts all views in a hierarchy. A Surface
represents a canvas context, and holds a reference to the view hierarchy rooted at a rootView
. The Surface
is also the main point of contact for the controller layer—all user interactions and draw calls enter the view hierarchy through method calls on a Surface
instance. This is somewhat analogous to UIKit's UIWindow
class.
Key Flows
This is where the fun happens! Everything above describes the components statically—this section is where everything comes together.
Displaying, i.e. layout and drawing
Here's how the view framework gets things onto the canvas.
As mentioned earlier, our controller is a React component. It initializes the view hierarchy and stores it in a Surface
instance. During the component's commit phase (i.e. in a useLayoutEffect
hook callback), we want to trigger a canvas update. This is how that happens:
- React invokes our
useLayoutEffect
callback. - We call the surface's
displayIfNeeded
method. - The surface calls the
displayIfNeeded
method on itsrootView
. - If the view is visible and if its contents are invalidated, it lays out its subviews by calling its
layoutSubviews
method. Next, it draws to the canvas in itsdraw
method. Finally, it propagates thedisplayIfNeeded
method to its subviews.
When a view is instantiated, its contents will always be invalidated. Thus, the first drawIfNeeded
call draws all visible views to the canvas.
Interaction handling
Our view framework defines a concept of an interaction: these are simply DOM events coupled with computed data that aids interaction handling in views. For example, the view framework (specifically our useCanvasInteraction
hook) transforms the event's global coordinates into the canvas' frame. We also normalize wheel event deltas here.
Because we want this abstraction layer to be as thin as possible, our view framework handles user interactions in the simplest way possible: all interactions are delivered to all views.
I was initially uncomfortable with this, as some types of views need to prevent events from propagating to their superviews. However, a proper event handling system was too complex to implement. Also, without an abstraction like UIGestureRecognizer
, getting nested scroll views to work together will be painful. In fact, this is complex enough that it was the subject of a few WWDC talks before Apple introduced the gesture recognizer API.
Thankfully, this simple interaction handling system seems to work well for our use case.
View
subclasses can receive interactions by overriding the handleInteraction
method. Here is a code snippet from our content views that respond to hover events:
handleInteraction(interaction: Interaction) {
switch (interaction.type) {
case 'mousemove':
this._handleMouseMove(interaction);
break;
}
}
This is what happens when a DOM event is received by callbacks defined in our useCanvasInteraction
hook:
- A callback defined in our
useCanvasInteraction
hook receives a DOM event. - The callback transforms the event into interactions.
- The callback calls the hook's
interactor
callback with the interaction. - The host React controller component can respond to this interaction. In our profiler,
CanvasPage
updates itsmouseLocation
state so that our tooltip follows the cursor. - The React controller component can also feed the interaction into its
Surface
instance. - The
Surface
instance will call itsrootView
'shandleInteractionAndPropagateToSubviews
method. This protected method defined onView
callsthis.handleInteraction
and propagates it to its subviews. In this manner, all views in the view hierarchy will receive the interaction.
State updates
When a view handles an interaction, it may need its state to be updated. We also want to delegate the responsibility of updating state to the controller layer.
We do this by defining delegate methods/callbacks on a view. In our profiler, our CanvasPage
React component passes onHover
callbacks to most of our content views. CanvasPage
can thus respond to data hovers by updating both the tooltip's contents and the states of all the content views.
The view framework does not prescribe how states should be updated; views are regular classes and can store and handle their state as they see fit.
When a view needs to be redrawn, its setNeedsDisplay
method should be called. This will set some flags up and down its superview and subview hierarchy. This does the following:
- The view's contents are invalidated, i.e. it sets
this.needsDisplay = true
. - The view will propagate the
setNeedsDisplay
call to all its subviews, invalidating their contents as well. This means that the entire view tree rooted at the current view will be invalidated. - The view will notify its superview that it has a subview that needs to be redrawn. This uses the private
_setSubviewsNeedDisplay
method to setthis.subviewsNeedDisplay = true
. The superview will then propagate this call up the view hierarchy until we reach the surface'srootView
.
During the host React controller component's next commit phase, the controller will invoke its surface's displayIfNeeded
method, which is forwarded to its root view. Because both subviewsNeedDisplay
and needsDisplay
have been set on invalidated views in the view hierarchy, the display process can efficiently find views that need redrawing and redraw only the views that are invalidated. Win!
Future improvements
Of course, we can make a ton of improvements to this framework, especially since this is a minimal implementation completed in a week.
The biggest improvement that I believe is possible but has eluded me so far is to further break down the View
class into separate injectable behaviors. It irks me that views that require their state to be derived from interactions (e.g. our pan/scroll/zoom views) cannot cleanly use layouters. This also means that such behaviors must be composed by nesting views instead of simply attaching behaviors to a view instance. Although the current design works, it can be improved.
Conclusion
The view framework outlined in these 2 posts has been a great boon for our work on the profiler. It can be useful in other complex canvas projects as well. Ping me on Twitter @taneliang if you're interested in reusing this framework!
I hope this has inspired some project ideas!