Exploring React Native’s PanResponder and LayoutAnimation

原文:https://medium.com/@andi.gu.ca/exploring-react-natives-panresponder-and-layoutanimation-dde77e7f4cc9

In previous posts, I’ve talked about the Animated API and why I consider it to be so powerful. However, the Animated API can only do so much on its own. Its key limitation: it is incapable of responding gestures beyond the scope of a ScrollView. There’s a lot of things that you can do with just a simple ScrollView, but I would argue that every mobile app is incomplete without gesture handling. It is comparable to using your text editor/IDE without keyboard shortcuts. Functional, yes, but pleasant to use? Probably not.

PanResponder is React Native’s way to allow you to detect and respond to user gestures. The API is quite complex, to say the least. It provides 5 gesture listeners, each called with two parameters that provide information about the position, time, and velocity of the gesture. If you want a full rundown, the docs are quite helpful.

In conjunction with the PanResponder, I will be introducing the experimental LayoutAnimation to create graceful transitions between different UI states. My goal with this post is not to explore every facet of each API, but just to give a brief introduction to a very simple use case: the ‘swipe-to-dismiss’ pattern. This pattern can be seen everywhere from Google Now to Android’s notifications. The result is shown below:

demo.gif

The Card Component (Gesture Response Mechanism)

The first part of this pattern is the cards themselves. They must respond to user gestures according to the following two rules:

  1. If the card has been swiped quickly, or it has been displaced from its original position by more than 50% of its width (at the time of the gesture release), it should fly off the screen. The definition of a ‘quick’ swipe is up to you.
  2. Otherwise, as soon as the user releases the card, it should spring back into its original place.

The entire implementation of this can be fit into just 42 lines.

export class SwipeableCard extends Component {
  translateX = new Animated.Value(0);
  _panResponder = PanResponder.create({
    onMoveShouldSetResponderCapture: () => true,
    onMoveShouldSetPanResponderCapture: () => true,
    onPanResponderMove: Animated.event([null, {dx: this.translateX}]),
    onPanResponderRelease: (e, {vx, dx}) => {
      const screenWidth = Dimensions.get("window").width;
      if (Math.abs(vx) >= 0.5 || Math.abs(dx) >= 0.5 * screenWidth) {
        Animated.timing(this.translateX, {
          toValue: dx > 0 ? screenWidth : -screenWidth,
          duration: 200
        }).start(this.props.onDismiss);
      } else {
        Animated.spring(this.translateX, {
          toValue: 0,
          bounciness: 10
        }).start();
      }
    }
  });

  render() {
    return (
      <View>
        <Animated.View
          style={{transform: [{translateX: this.translateX}], height: 75}} {...this._panResponder.panHandlers}>
          <Card>
            <CardItem>
              <Body>
              <Text>
                {this.props.title}
              </Text>
              </Body>
            </CardItem>
          </Card>
        </Animated.View>

You can probably tell that the core is really the PanResponder.create({...})section. The problem is that the {...} seems like gibberish at first glance. Let’s break is down:

  • The first two ‘shouldCapture’ functions are simply telling the responder to respond (how surprising!) to any gestures it captures . If they were set to return false, the PanResponder would simply ignore all touch and gesture events.
  • onPanResponderMove is set to Animated.event([null, {dx: this.translateX}]). Why? Looking at the docs, we can see that the onPanResponderMove function is called every time the user’s finger moves. It is called with gestureState, which contains dx — this tells us how far the user’s finger has moved since the gesture started. Thus, if we want to achieve a realistic ‘swiping’ sensation, we must tell this.translateX to follow the user’s finger. Unfortunately, PanResponder does not support useNativeDriver: true as of RN-0.46.4 — your animations may take a performance hit, but for simple applications like this, speed should not be affected at all.

Core Logic: onPanResponderRelease

The two ‘rules’ that we laid out for the swipeable cards are really concerned with the gesture *release, *rather than the gesture itself.

const screenWidth = Dimensions.get("window").width;
if (Math.abs(vx) >= 0.5 || Math.abs(dx) >= 0.5 * screenWidth) {
 Animated.timing(this.translateX, {
  toValue: dx > 0 ? screenWidth : -screenWidth,
  duration: 200
 }).start();
} else {
 Animated.spring(this.translateX, {
  toValue: 0,
  bounciness: 10
 }).start();
}

The two parameters vx and dx are important to understand:

  • dx is the horizontal distance that the user’s finger has traveled since the start of the gesture. It is important to note that this is a vector quantity: it is positive for a displacement towards the right and negative for a displacement to the left.
  • vx is the instantaneous velocity of the swipe gesture in the horizontal directionat release. Like dx, vx is a vector quantity and follows the same sign scheme.

With these two pieces of data, it becomes easy to follow the two rules laid out previously. If the absolute value of vx exceeds 0.5, or the absolute value of dxexceeds half the screen width, the card should fly off the screen. Why absolute value? Remember that both vx and dx are vector quantities, but what we are watching for is really speed and distance traveled, rather than velocity and displacement. Thus we apply an absolute value to get the magnitude of each quantity.

This is not to say that the direction of these quantities is entirely irrelevant. If the displacement was to the right at the time of release, it only makes sense that the card fly off to the right, and likewise for the left. Thus, when dx > 0, the card animates to +screenWidth, otherwise it animates to -screenWidth.

With this logic in place, we now have a gesture responsive card component.

UI State Transitions (LayoutAnimation Mechanism)

Now that we have a component, it’s time to put it together form a complete UI. The goal is to render a list of these swipeable cards, but to animate a smooth transition when a card is dismissed. Although this may seem insignificant, it can really make a difference in a user’s experience.

Which looks better to you? .gif

Clearly, when we introduce animations between different UI states, or ‘layouts’, as React Native calls them, the user experience becomes more pleasant and intuitive. Using the LayoutAnimation API, it becomes easy to configure these transitions.

import React, {Component} from "react";
import {LayoutAnimation, UIManager} from 'react-native';
import {Body, Container, Header, Title, View} from "native-base";
import {SwipeableCard} from "./SwipableCard";

export class PanResponderDemo extends Component {
  titles = new Array(10).fill(null).map((_, i) => `Card #${i}`);
  state = {
    closedIndices: []
  };

  constructor(props) {
    super(props);
    UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true);
    this.shouldRender = this.shouldRender.bind(this);
  }

  shouldRender(index) {
    return this.state.closedIndices.indexOf(index) === -1
  }

  render() {
    return (
      <Container>
        <Header>
          <Body>
          <Title>
            Pan Responder Demo
          </Title>
          </Body>
        </Header>
        {this.titles.map((title, i) => this.shouldRender(i) &&
          <View key={i}><SwipeableCard title={title} onDismiss={() => {
            if ([...new Array(this.titles.length)].slice(i + 1, this.titles.length).some(this.shouldRender)) {
              LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
            }
            this.setState({
              closedIndices: [...this.state.closedIndices, i]
            })
          }}/></View>)}
      </Container>
    );
  }
}

It may strike you as odd that the ‘data source’ for the list, this.titles, does not change as the cards are swiped. To understand why, it is important to understand how LayoutAnimation works.

In general, the render method of a component is called every time there is a change to state or props (unless shouldComponentUpdate returns false). However, the UI changes are applied instantly, which could perhaps be visually jarring. LayoutAnimation solves this problem — it provides a way animate the transition between two consecutive render calls. It does notapply to every single re-render, rather, every time it is called, it only animates the very next transition.

What is ‘closedIndices’?

So, back to the initial question — why is the data source for the list not in state, and why is it constant? Because LayoutAnimation works when views keep the same key between state changes. Since the key in this case is the index of the item, if the array changes, the key of each view will change, and thus the animation will break. To get around this, we keep track of an array of ignored indices, or indices of the data source that should not be rendered. This array needs to go in the state, since changes to it mean the UI should update.

Beyond this, there is one small quirk of LayoutAnimation that must be addressed. Since the animation applies to **any **changes in the UI, we must be careful to only call it when we know it will result in the type of animation we desire. It is desirable only to animate movement of cards — all other UI changes should not be animated. More specifically, when the dismissed card is the last card in the list, there should not be any animation — if this case is left unaccounted for, there will be a animated fade-out effect. Try it yourself! To fix this, all we need to do is check if there are any cards below the dismissed card that are still rendered, with […new Array(this.titles.length)].slice(i + 1, this.titles.length).some(this.shouldRender).

And It’s Done!

Although this was a very simple use case for React Native’s UI APIs, I hope that it has demonstrated how powerful they can be. It is now easier than ever to create a complex yet responsive UI in just a few lines of code.

code

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,922评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,591评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,546评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,467评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,553评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,580评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,588评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,334评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,780评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,092评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,270评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,925评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,573评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,194评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,437评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,154评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,127评论 2 352

推荐阅读更多精彩内容

  • rljs by sennchi Timeline of History Part One The Cognitiv...
    sennchi阅读 7,319评论 0 10
  • 寒假里我喜欢听节目,但是感觉自己听得很盲目,听了一批又一批的节目,我想来聊聊节目的那些事。 我听的节目大致可以...
  • [愉快]充实的一天!感谢阿波罗老师和各位大侠的辛勤付出[玫瑰]我是王闻韬,今天拆的是一本讲目标管理的书《只管去做》...
    王闻韬阅读 129评论 0 0
  • 今天是一个值得纪念的日子,因为松鼠家的三个孩子成年了。成年意味着就有像爸爸妈妈一样坚硬的牙齿,可以像爸爸妈妈那样外...
    一叶兰芷阅读 789评论 0 3
  • robots.txt是一个纯文本文件,是搜索引擎蜘蛛爬行网站的时候要访问的第一个文件,当蜘蛛访问一个站点时,它会首...
    听说昵称不能太美阅读 1,395评论 0 0