关于一次在react-native中使用svg制作边框动画的探索

1,809 阅读4分钟

关于一次在react-native中使用svg制作边框动画的探索

围绕边框旋转?svg!

众所周知react-native是前端猿们喜闻乐见的一门技术栈,因其高效的跨平台能力,以及低成本的技术迁移(相对react开发者而言),深受前端猿的喜爱,只需要你懂一点点原生开发知识和亿点点react开发知识就可以轻松玩转,所以看见这个需求的时候,首先想到的就是svg

有小伙伴可能会问为什么不用react自己art类库呢?不要问,问就是不想用,react-native有art的类库可以用,但是相对svg,api有所不一样,包括库本身也还有一些api未实现,比如安卓端shape的fill属性:


  /**
   * Sets up {@link #mPaint} according to the props set on a shadow view. Returns {@code true}
   * if the fill should be drawn, {@code false} if not.
   */
  protected boolean setupFillPaint(Paint paint, float opacity) {
    if (mBrushData != null && mBrushData.length > 0) {
      paint.reset();
      paint.setFlags(Paint.ANTI_ALIAS_FLAG);
      paint.setStyle(Paint.Style.FILL);
      int colorType = (int) mBrushData[0];
      switch (colorType) {
        case COLOR_TYPE_SOLID_COLOR:
          paint.setARGB(
              (int) (mBrushData.length > 4 ? mBrushData[4] * opacity * 255 : opacity * 255),
              (int) (mBrushData[1] * 255),
              (int) (mBrushData[2] * 255),
              (int) (mBrushData[3] * 255));
          break;
        case COLOR_TYPE_LINEAR_GRADIENT:
          // For mBrushData format refer to LinearGradient and insertColorStopsIntoArray functions in ReactNativeART.js
          if (mBrushData.length < 5) {
            FLog.w(ReactConstants.TAG,
              "[ARTShapeShadowNode setupFillPaint] expects 5 elements, received "
              + mBrushData.length);
            return false;
          }
          float gradientStartX = mBrushData[1] * mScale;
          float gradientStartY = mBrushData[2] * mScale;
          float gradientEndX = mBrushData[3] * mScale;
          float gradientEndY = mBrushData[4] * mScale;
          int stops = (mBrushData.length - 5) / 5;
          int[] colors = null;
          float[] positions = null;
          if (stops > 0) {
            colors = new int[stops];
            positions = new float[stops];
            for (int i=0; i<stops; i++) {
              positions[i] = mBrushData[5 + 4*stops + i];
              int r = (int) (255 * mBrushData[5 + 4*i + 0]);
              int g = (int) (255 * mBrushData[5 + 4*i + 1]);
              int b = (int) (255 * mBrushData[5 + 4*i + 2]);
              int a = (int) (255 * mBrushData[5 + 4*i + 3]);
              colors[i] = Color.argb(a, r, g, b);
            }
          }
          paint.setShader(
            new LinearGradient(
              gradientStartX, gradientStartY,
              gradientEndX, gradientEndY,
              colors, positions,
              Shader.TileMode.CLAMP
            )
          );
          break;
        case COLOR_TYPE_RADIAL_GRADIENT:
          // TODO(6352048): Support radial gradient etc.
        case COLOR_TYPE_PATTERN:
          // TODO(6352048): Support patterns etc.
        default:
          FLog.w(ReactConstants.TAG, "ART: Color type " + colorType + " not supported!");
      }
      if (mShadowOpacity > 0) {
        paint.setShadowLayer(mShadowRadius, mShadowOffsetX, mShadowOffsetY, mShadowColor);
      }
      return true;
    }
    return false;
  }

未实现patterns填充底色,也就是用图像填充,看见这么多TODO你还敢用吗?而react-native-svg这个库兼容了web端svg的很多标签属性,用起来更舒服,不会有附加的学习负担,懒人当然更喜欢这个。

那么该从何下手呢?首先看看svg如何画边框!

既然兼容web标签属性,那么看看css怎么制作一个会动的标签吧。打开w3c的svg章节,翻到 SVG rect这一章节,rect属性包括:

  • x:绘制起点x轴坐标
  • y:绘制起点y轴坐标
  • rx,ry:rx 和 ry 属性可使矩形产生圆角
  • width,height:宽度和高度
  • style:
    • fill 属性定义矩形的填充颜色(rgb 值、颜色名或者十六进制值)
    • stroke-width 属性定义矩形边框的宽度
    • stroke 属性定义矩形边框的颜色
    • stroke-dasharray 文档没写,用于画虚线 [虚线的长度,虚线之间的间隔长度]
    • stroke-dashoffset 文档依然没写,用于设置 虚线起点的偏移 看了属性之后大概猜一下,估计就是用stroke属性来画边框了,stroke-width定义边框的宽度,stroke-dasharray定义边框的长度和间隔,stroke-dashoffset定义起点的偏移,这样就能把边框给画出来了。

但是怎么才能让他转起来呢?动画!

那么怎么才能让固定长度的边框转起来呢,这里又是个小细节,我们利用stroke-dashoffset属性给边框添加线性变化的偏移,这样边框就能转起来了。

在css中实现svg标签的动画比较简单,只需要定义一个animation动画给stroke-dashoffset就可以了,那么在没有animation标签支持的react-native下,如何才能动画呢?答案是使用react-native内部提供的Animated方法,由于Rect属于三方组件,所以需要用Animated.createAnimatedComponent(Rect)包装一下才能使用。

做到这里,已经大概知道需要怎么实现了,需要确定strokeDasharray虚线的长度和虚线的间隔,以及通过用动画算法计算出偏移量,从而实现动画,那么这里需要注意的是,如果要让动画循环播放,并且可以设置多久转满一圈,也就是duration,这个偏移量就不能随便设置,得算出矩形的周长,以周长为一个offset,算周长就得翻翻初中数学了:

init = () => {
    // 计算周长
    const { width, height } = this.state;
    const { borderRadius } = this.props;
    let rx = borderRadius;
    let ry = borderRadius;
    const maxBorderRadius = width < height ? width : height;

    if (borderRadius <= 0) {
        rx = 0;
        ry = 0;
    }

    if (borderRadius >= maxBorderRadius / 2) {
        rx = maxBorderRadius / 2;
        ry = maxBorderRadius / 2;
    }

    this.rx = rx;
    this.ry = ry;

    const borderRadiusLength = Math.PI * rx * 2; // 圆角的周长
    const lineLength = 2 * width + 2 * height - 8 * rx;

    this.setState({
        allLength: borderRadiusLength + lineLength,
    });
};

然后自然而然的咱们就写出了这样的布局代码:

<Svg
    viewBox={`0 0 ${containerWidth} ${containerHeight}`}
    width={containerWidth}
    height={containerHeight}
    style={{
        position: 'absolute',
        zIndex: 9999,
        elevation: 1,
    }}
>
    <AnimatedRect
        ref={ref => (this._myRect = ref)}
        x={borderWith}
        y={borderWith}
        rx={this.rx}
        ry={this.ry}
        width={width}
        height={height}
        fill="none"
        stroke={color}
        strokeWidth={borderWith}
        strokeDasharray={[
            borderLength,
            allLength - borderLength,
        ]}
    />
</Svg>

解释一下为什么strokeDasharray的虚线间隔需要设置成周长减去边框长度,因为咱们需要循环动画,当一个duration跑完一个周长的offset后,需要回到初始值0继续跑下一个offset,这样咱们的间隔加上边框长度就得刚好等于一个周长才行,也可以继续跑下一个offset的倍数,方法很多,懂的自然懂,其中viewBox以及fill什么的感兴趣可以自己翻一翻文档看看。

最后看一下动画api的调用方式:

···
constructor(props) {
    super(props);
    this.state = {
        rectDasharray: new Animated.Value(0),
    };
    // 监听值变化,并设置
    this.state.rectDasharray.addListener(rectDasharray => {
        this._myRect &&
            this._myRect.setNativeProps({
                strokeDashoffset: rectDasharray.value,
            });
    });
}
···
animate = () => {
	// 循环动画
    this.state.rectDasharray.setValue(0);

    Animated.timing(this.state.rectDasharray, {
        toValue:
            (this.props.direction == 'right' ? -1 : 1) *
            this.state.allLength,
        duration: this.props.duration,
        easing: Easing.linear,
        useNativeDriver: true,
    }).start(this.animate);
};

setNativeProps可以想象你正在用原生js的方式设置dom标签的style属性,类比过来原生js就是你正在写的react-native,dom就是Rect标签,当然你也可以使用Animated.interpolate差值计算的方式来设置值,因为我是懒人所以用了比较笨的方法,完事儿差不多就酱