Skip to content
academia.sh

Lesson 13 / 20

Gestures and Animations

If a gesture's position is written to the tree as a prop, every frame produces an update command. If the gesture is driven on the native side, the tree is kept in the frames between. The lesson measures the two paths on the same gesture.

Published Updated

In this lesson

  • Explains the difference between writing a gesture's position to the tree and driving it on the native side
  • Measures that when the position is written to the tree as a prop, every frame produces an update command
  • Measures that in a natively driven gesture the card is kept in the frames between
  • Shows that writing the position to the tree once when the gesture ends is a single update
Contents

The previous lesson covered a change that comes from outside. A change also comes from a finger: a card is dragged, a panel slides open. A gesture is not a single event but a change that proceeds frame by frame. The card’s position changes in every frame, and the question is this: does every step of this change pass through the view tree, or does the tree see only the start and the end?

The question of the course does not change: what does this update do to the node in the view tree? The fate is kept, updated, mounted, or unmounted; the total of the updated, mounted, and unmounted nodes is the commands. We do not assign a fate by hand; the rule gives it:

// fate.mjs — classifies what an update does to each node of the view tree
export const FATES = ["kept", "updated", "mounted", "unmounted"];

export function el(type, props = {}, children = [], key = null) {
  return { type, props, children, key };
}

function segment(node) {
  return node.key === null ? node.type : `${node.type}[${node.key}]`;
}

function identity(node, index) {
  return node.key === null ? `#${index}` : `key:${node.key}`;
}

function sameProps(a, b) {
  const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
  return names.every((n) => JSON.stringify(a[n]) === JSON.stringify(b[n]));
}

function whole(node, path, fate, ops) {
  const here = `${path}/${segment(node)}`;
  ops.push({ fate, path: here });
  node.children.forEach((child) => whole(child, here, fate, ops));
}

export function reconcile(prev, next, path = "", ops = []) {
  if (prev === null) {
    whole(next, path, "mounted", ops);
    return ops;
  }
  if (next === null) {
    whole(prev, path, "unmounted", ops);
    return ops;
  }
  if (prev.type !== next.type || prev.key !== next.key) {
    whole(prev, path, "unmounted", ops);
    whole(next, path, "mounted", ops);
    return ops;
  }
  const here = `${path}/${segment(next)}`;
  ops.push({ fate: sameProps(prev.props, next.props) ? "kept" : "updated", path: here });
  const before = new Map(prev.children.map((child, i) => [identity(child, i), child]));
  const matched = new Set();
  next.children.forEach((child, i) => {
    const id = identity(child, i);
    matched.add(id);
    reconcile(before.has(id) ? before.get(id) : null, child, here, ops);
  });
  prev.children.forEach((child, i) => {
    if (!matched.has(identity(child, i))) reconcile(child, null, here, ops);
  });
  return ops;
}

export function printOps(ops) {
  ops.forEach((op) => console.log(`${op.fate}: ${op.path}`));
}

export function countFates(ops) {
  for (const name of FATES) {
    console.log(`${name}: ${ops.filter((op) => op.fate === name).length}`);
  }
  console.log(`commands: ${ops.filter((op) => op.fate !== "kept").length}`);
}

The model builds this fate’s rule; the model is not the framework itself. The rule compares not frames but pairs of trees; how many frames a gesture lasts, we understand from how many times it visited the tree.

When the Position Is Written to the Tree

The first way to drive a gesture is to hold the position in the app’s state. As the finger moves, the state changes, the tree is rebuilt, and the card’s position prop carries the new value. For each frame two trees are compared, and the difference goes to the native side as commands.

AL1. The model builds the card as a single Card inside Deck; Card carries the position as a prop and has a Text under it. The position is given with numbers such as 0, 40, 80; the numbers are the identity of the frame, not a measure.

// deck.mjs — builds the card deck's view tree for the two drive paths
import { el } from "./fate.mjs";

export function renderDeck(position) {
  return el("Deck", {}, [
    el("Card", { position: position }, [el("Text", { text: "Order" })]),
  ]);
}

export function renderDriven() {
  return el("Deck", {}, [
    el("Card", { driver: "native" }, [el("Text", { text: "Order" })]),
  ]);
}

renderDeck writes the position as a prop. renderDriven does not write the position to the tree; the card only reports that the gesture is driven on the native side. In the framework the second path is built by handing the gesture over to the native driver.

// Platform code: SlideCard
function SlideCard({ x }) {
  useEffect(() => {
    Animated.timing(x, { toValue: 160, useNativeDriver: true }).start();
  }, []);
  return <Animated.View style={{ transform: [{ translateX: x }] }} />;
}

This fragment is not measured; the model does not run the native driver. The hook in the fragment does not pass the position through state: the start and end values of the gesture are given to the native side once, and the native side produces the frames in between.

The first measurement is a single frame transition in which the position is written to the tree: the card goes from position 0 to position 40.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderDeck } from "./deck.mjs";

const ops = reconcile(renderDeck(0), renderDeck(40));
printOps(ops);
countFates(ops);
kept: /Deck
updated: /Deck/Card
kept: /Deck/Card/Text
kept: 2
updated: 1
mounted: 0
unmounted: 0
commands: 1

/Deck/Card is updated, because the position prop changed. Deck and Text are kept; the text under the card is not affected by the gesture. The number of commands is 1: a single frame carried a single update.

One Command per Frame

A gesture does not end in a single frame. The same transition is repeated in every frame, and every repetition adds a command. The helper below compares consecutive frames and returns the command count of each transition.

// frames.mjs — gives the command count of each transition between consecutive frames
import { reconcile } from "./fate.mjs";

export function stepCosts(frames, render) {
  const costs = [];
  for (let i = 1; i < frames.length; i++) {
    const ops = reconcile(render(frames[i - 1]), render(frames[i]));
    costs.push(ops.filter((op) => op.fate !== "kept").length);
  }
  return costs;
}

Two position sequences, one with three frames and one with four, are passed through the path that writes the position to the tree. For comparison, the same sequences are also passed through the natively driven path.

// main.mjs
import { stepCosts } from "./frames.mjs";
import { renderDeck, renderDriven } from "./deck.mjs";

console.log("position in tree, 3 frames: " + stepCosts([0, 40, 80], renderDeck).join(" "));
console.log("position in tree, 4 frames: " + stepCosts([0, 40, 80, 120], renderDeck).join(" "));
console.log("natively driven, 3 frames: " + stepCosts([0, 40, 80], renderDriven).join(" "));
console.log("natively driven, 4 frames: " + stepCosts([0, 40, 80, 120], renderDriven).join(" "));
position in tree, 3 frames: 1 1
position in tree, 4 frames: 1 1 1
natively driven, 3 frames: 0 0
natively driven, 4 frames: 0 0 0

As long as the position is in the tree, every transition is one command, and as the gesture gets longer the number of commands grows with the number of frames. In the natively driven path the cost of every transition is 0; the length of the gesture does not show in the tree.

A Natively Driven Gesture

The native driver advances the gesture from start to finish by itself. The tree knows only that the card exists and that the gesture is driven on the native side. In the frames between, the tree stays the same.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderDriven } from "./deck.mjs";

const ops = reconcile(renderDriven(), renderDriven());
printOps(ops);
countFates(ops);
kept: /Deck
kept: /Deck/Card
kept: /Deck/Card/Text
kept: 3
updated: 0
mounted: 0
unmounted: 0
commands: 0

/Deck/Card is kept. Even though the card is sliding on the screen, nothing happens for the tree: all three nodes are kept, commands is 0. The smoothness of the gesture is the native side’s job; the position prop never enters the tree, so there is no need to compare again for each frame.

Reading the Source Fragment Line by Line

In the SlideCard fragment, the whole gesture fits in one call. The component takes an animation value x: the start position is inside this value and is not held in state. The useEffect block is a side effect that runs once at launch; it starts the gesture. toValue in the call gives the end position, and useNativeDriver: true says that the frames in between will be produced on the native side. The return line is the drawing and binds the position to the card as a transform.

What is missing from the fragment is also instructive: there is no state call that updates the position. While the gesture proceeds, the app’s state does not change, so the tree is not rebuilt. renderDriven in the model is the counterpart of this absence in the tree: the card carries no position, it only reports that the gesture is driven on the native side. A different way of writing that holds the position in state would correspond to renderDeck and would require a call that changes the state for every frame.

The choice between these two ways of writing has a single condition: whether the app logic needs the gesture’s intermediate values. If the logic will read the intermediate position, for example if something else will happen when the card passes a certain point, the value is held in state. If only a visual slide is needed, the native driver is enough.

More Than One Card

On a real screen, a single card does not slide at a time. When a deck opens, several cards move together. If the position is written to the tree, each card’s position is a separate prop, and all of them are updated in every transition. We measure this by changing the number of cards.

// main.mjs
import { el } from "./fate.mjs";
import { stepCosts } from "./frames.mjs";

function renderMany(count, driven) {
  return (position) => el("Deck", {}, Array.from({ length: count }, (_, i) =>
    el("Card", driven ? { driver: "native" } : { position: position },
      [el("Text", { text: "Order" })], "c" + i)));
}

const frames = [0, 40, 80];
console.log("1 card, position in tree: " + stepCosts(frames, renderMany(1, false)).join(" "));
console.log("2 cards, position in tree: " + stepCosts(frames, renderMany(2, false)).join(" "));
console.log("3 cards, position in tree: " + stepCosts(frames, renderMany(3, false)).join(" "));
console.log("3 cards, natively driven: " + stepCosts(frames, renderMany(3, true)).join(" "));
1 card, position in tree: 1 1
2 cards, position in tree: 2 2
3 cards, position in tree: 3 3
3 cards, natively driven: 0 0

The cost of every transition equals the number of cards: 1 for one card, 2 for two cards, 3 for three cards. The total cost is therefore the product of two factors: the number of frames and the number of cards. In the natively driven path, even if all three cards slide, the cost is zero. The more nodes a gesture involves, the more the price of writing the position to the tree grows.

This product explains why a gesture can be expensive, but it gives no threshold. How many cards count as “many” depends on the app and the platform; this lesson measures only which factors the cost depends on.

Counterexample: Where the Position Is Needed in the Tree

The native driver is not always the right solution. If the card’s position determines the state of another node, for example if a drop area is to be highlighted when the dragged card comes over it, the app needs to know the intermediate position. The native driver does not pass this information to the app in every frame. In this case either the position is held in state and the price is paid, or an event is sent to the app only at the moment of the threshold. The second option writes only that moment to the tree: 0 commands throughout the gesture, one update at the moment of the threshold.

In this choice the fate of the tree does not make the decision; the function of the gesture makes it. The fate only puts the prices of the two paths side by side.

The Bounding Measurement

It would be wrong to say that a natively driven gesture never visits the tree. When the gesture ends, the app needs to know the card’s final position: whether the card has settled in place, been deleted, or closed. This information is written once from the state to the tree.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderDeck, renderDriven } from "./deck.mjs";

const ops = reconcile(renderDriven(), renderDeck(160));
printOps(ops);
countFates(ops);
kept: /Deck
updated: /Deck/Card
kept: /Deck/Card/Text
kept: 2
updated: 1
mounted: 0
unmounted: 0
commands: 1

This time the card is updated: the driver prop went and the position prop came. The number of commands is 1. Throughout the gesture 0 commands, at the end 1 command. The fate does not measure whether the gesture is smooth; it measures what was written to the tree. A natively driven gesture has no commands in the tree, but the position written at the end is still an update.

Common Misconceptions

  • “Every animation is driven natively, so the tree never changes.” The driver is a choice. If the position is held in state, every frame enters the tree.
  • “If commands is 0, the gesture is smooth.” The command count measures the writing between the tree and the native side; it does not measure smoothness.
  • “When the gesture ends, the tree stays the same.” If the final position is needed by the app, it is written once, and this is a single update.
  • “The number of cards does not change the tree cost.” If the position is written to the tree, each card is a separate update. In the measurement with more than one card, the commands per transition came out equal to the number of cards.
  • “Using the native driver complicates the code, and the tree is fast enough anyway.” This is a performance claim, and the lesson does not measure it. What is measured is only the number of commands written to the tree; tree speed or frame time was not measured.
  • “Driving a gesture from state is wrong.” If logic depends on the intermediate position, driving from state is the right choice. What is wrong is writing every frame to the tree when there is no need.
  • “A gesture’s state and the tree are the same thing.” The animation value is the number inside the gesture; the tree is the screen’s state at that moment. The native driver advances the number but does not write it to the tree. This separation of two different things explains the zero commands in the measurements.
  • “More than one gesture shares the same command.” The position of each card written to the tree is a separate prop; the commands multiply by the number of cards. No sharing was seen in the multi-card measurement.
  • “The duration of the animation determines the number of commands.” Duration was not measured in this lesson. What was measured is the number of transitions: when the position is in the tree, every frame brings a transition and a command; how the duration converts to a number of frames depends on the platform and is not part of this lesson.
  • “The native driver is only for scrolling.” The driver can also be used for other visual props such as a transform, besides position. The common condition is that the intermediate values are not needed by the app logic.
  • “A gesture and a transition are the same thing.” A gesture is the continuous flow of many frames; a transition is a single comparison between two trees. The lesson measures the gesture by dividing it into transitions, and this division explains how the tree rule sees the gesture.
  • “Removing the card from the tree stops the gesture.” If the card is unmounted, the gesture also disappears; but this is a different fate. Removing the card in the middle of a gesture produces an unmounted node in the tree, and this was not the subject of the measurement.

Summary

  • This lesson’s fate: kept. In a natively driven gesture, /Deck/Card is kept in the frames between and commands is 0.
  • If the position is written to the tree as a prop, every frame updates the /Deck/Card node and the number of commands grows with the number of frames.
  • In a natively driven gesture, the number of commands does not change as the number of frames grows.
  • Writing the position to the tree once when the gesture ends is a single update command.
  • The fate classifies not the smoothness of the gesture but what is written to the tree.

Next Step

With this lesson the layers of the app are complete: navigation, state, network, storage, notifications, and gestures. Each lesson measured the fate of a change in the tree. In the lessons that follow, the same measure returns to performance questions. The first lesson of the next section, List Performance, measures what happens to the row nodes when an item is added to the head of a list.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close