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 all Views inherit from.
  • Views that manipulate their subviews in response to user interactions. These include HorizontalPanAndZoomView, VerticalScrollView and ResizableSplitView. 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, and FlamechartView.
Class diagram showing some key View methods and a few of its subclasses in the profiler codebase.

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's origin.x.
  • desiredHeightLayout: sets all views' heights to those returned by their desiredSize 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 with verticallyStackedLayout.

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.

Class diagram showing the key Surface properties, methods, and associations.

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:

  1. React invokes our useLayoutEffect callback.
  2. We call the surface's displayIfNeeded method.
  3. The surface calls the displayIfNeeded method on its rootView.
  4. 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 its draw method. Finally, it propagates the displayIfNeeded method to its subviews.
Sequence diagram showing the view framework's display flow, triggered by CanvasPage and propagating through the surface's root view view1 and 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:

  1. A callback defined in our useCanvasInteraction hook receives a DOM event.
  2. The callback transforms the event into interactions.
  3. The callback calls the hook's interactor callback with the interaction.
  4. The host React controller component can respond to this interaction. In our profiler, CanvasPage updates its mouseLocation state so that our tooltip follows the cursor.
  5. The React controller component can also feed the interaction into its Surface instance.
  6. The Surface instance will call its rootView's handleInteractionAndPropagateToSubviews method. This protected method defined on View calls this.handleInteraction and propagates it to its subviews. In this manner, all views in the view hierarchy will receive the interaction.
Sequence diagram showing how interactions propagate through a surface's root view view1 and its subviews.

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:

  1. The view's contents are invalidated, i.e. it sets this.needsDisplay = true.
  2. 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.
  3. The view will notify its superview that it has a subview that needs to be redrawn. This uses the private _setSubviewsNeedDisplay method to set this.subviewsNeedDisplay = true. The superview will then propagate this call up the view hierarchy until we reach the surface's rootView.
Sequence diagram showing a state update on view: all recursive subviews and superviews up the chain are invalidated.

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!