React Native 不能实现仿真翻页动画?包可以的👆️

641 阅读12分钟

22 年,我就尝试过实现一个自定义阅读器(现在新开坑了,但是还没有上传github哈),也在掘金发过对应的文章传送门(这是旧的)。虽然当时成功实现了文本的计算绘制和分页切换,但对于所有阅读器都具备的翻页动画,我一直没有找到合适的实现思路。最近,空闲时间我又重新拾起这个项目,第一个重点就是开始研究如何实现这个动画效果。

最终实现效果

Video_20241105_010153_751.gif

实现的过程以及遇到的问题,等我在下面来一一阐述哈。

初步思路

最开始我的分页只是简单是使用组件Text来实现基础翻页文章,但是后续又改用了Skia这个库来重新实现,虽然绘制我是改用的是Skia来做,但翻页动画原基础翻页实现文章我并没有想到用Skia来实现。然后,我想着能不能0基础来自己实现一个原生组件,利用原生代码来处理文本绘制和动画,但在研究了一段时间 Kotlin 后,emm发现我还是太菜了,没办法快速理解学好 Kotlin,所以我也就决定放弃了这个方案。然后,我将目光重新投向 Skia,想到在web上面看到过有人用canvas2d来实现过很多非常奇妙的效果,那我其实是不是也可以用直接ALL IN Skia来实现呢??? 然后我就在谷歌搜索Skia transition的关键词,然后就看到了一个视频,展示了和我想要的翻页效果很类似。观看完视频后,我对代码实现已经有了整体的认知,并了解到了一种新的知识——着色器。

着色器/GLSL语法简介

着色器(Shader)是Skia中的一种用于图形渲染的程序,能够对图像进行处理。GLSL是OpenGL着色器语言,是着色器编程的语法。(ps:既然是简洁,那我就不展开了,感兴趣的可以自行搜索哈哈)

翻页动画着色器实现

在实现翻页动画时,我们需要三个参数:当前图像、下一页图像以及页面滑动的进度。前两个参数由 Shader 组件的子组件来决定输入(当前图像和下一页图像),第三个参数则需要使用手势管理库react-native-gesture-handler 来记录手的左右滑动并计算出 progress。然后对于着色器来说,progress 需要进行归一化处理才能使用,下面是一个线性动画的示例。

/**
 * 线性过渡效果
 * 使用 GLSL 实现的简单线性混合过渡
 * 
 * @description
 * 这个过渡效果会根据 progress 值在两个图像之间进行线性插值
 * - 当 progress = 0 时,显示起始图像
 * - 当 progress = 1 时,显示目标图像
 * - 中间值会按比例混合两个图像
 */
`
vec4 transition(vec2 uv) {
  // mix 是 GLSL 内置函数,用于线性插值
  // getFromColor 获取起始图像在当前UV坐标的颜色
  // getToColor 获取目标图像在当前UV坐标的颜色
  // progress 是过渡进度(0.0 到 1.0)
  return mix(
    getFromColor(uv),  // 起始图像颜色
    getToColor(uv),    // 目标图像颜色
    progress           // 混合程度
  );
}

手势处理逻辑

在手势处理方面,我对原有逻辑进行了改进: 原先的逻辑代码是:


  const createRightPanGesture = () => Gesture.Pan()
    .onBegin(() => {
      // 初始化进度
      progressRight.value = 0;
    })//激活滑动范围
    .activeOffsetX(GESTURE_CONFIG.ACTIVE_OFFSET_X)
    .onChange((pos) => {
      // 更新进度
      progressRight.value = clamp(
        progressRight.value + pos.changeX / SCREEN_WIDTH,
        ...GESTURE_CONFIG.PROGRESS_BOUNDS
      );
    })
    .onEnd(({ x }) => {
      // 结束滑动
      runOnJS(handlePreviousPage)();
      progressRight.value = withTiming(
        1,
        { duration: GESTURE_CONFIG.ANIMATION_DURATION }
      );
    });

  const createLeftPanGesture = () => Gesture.Pan()
    .onBegin(() => {
      progressLeft.value = 0;
    })
    .activeOffsetX(-GESTURE_CONFIG.ACTIVE_OFFSET_X)
    .onChange((pos) => {
      progressLeft.value = clamp(
        progressLeft.value - pos.changeX / SCREEN_WIDTH,
        ...GESTURE_CONFIG.PROGRESS_BOUNDS
      );
    })
    .onEnd(({ x }) => {
      runOnJS(handleNextPage)();
      progressLeft.value = withTiming(
        1,
        { duration: GESTURE_CONFIG.ANIMATION_DURATION }
      );
    });

改进后的逻辑代码是:

  1. 如果用户在左滑/右滑后又反方向滑动,则取消本次翻页,毕竟用户可能后悔了,不想要翻页了。
  2. 如果本次滑动有效,则将 progress 平滑过渡到结束状态,这样翻页动画就能够看起来顺滑很多。

以下是改进后的代码示例:

// 改进后的手势处理代码,增加监听onStart事件
  const createRightPanGesture = () => Gesture.Pan()
    .onBegin(() => {
      progressRight.value = 0;
    })
    .activeOffsetX(GESTURE_CONFIG.ACTIVE_OFFSET_X)
    .onStart(({x}) => {
      // 记录滑动起始位置,用于后面对比用户滑动距离
      startPointX.value = x;
    })
    .onChange((pos) => {
      progressRight.value = clamp(
        progressRight.value + pos.changeX / SCREEN_WIDTH,
        ...GESTURE_CONFIG.PROGRESS_BOUNDS
      );
    })
    .onEnd(({ x }) => {
      // 如果滑动距离小于起始位置,则取消翻页,并恢复进度
      if (x <= startPointX.value) {
        progressRight.value = withTiming(0, {
          duration: GESTURE_CONFIG.ANIMATION_DURATION
        });
        return;
      }

      runOnJS(handlePreviousPage)();
      progressRight.value = withTiming(
        1,
        { duration: GESTURE_CONFIG.ANIMATION_DURATION }
      );
    });

解决图像模糊问题

模糊的图片

88150a6b0805e5b75a50741fb31dac2.jpg

在基于旧的代码进行改造下,我使用了原本的文字分页布局和计算逻辑,本地测试代码时,我一开始是直接使用 Glyphs 替换原本代码中的图片组件位置。然而,登登登登!!运行后却出现了错误提示:“Glyphs 不能作为 Shader 的子元素”(ps:还是理解太浅了,当时我并不知道着色器其实需要输入的是图片组件)。经过重新的理解,我才意识到着色器的输入是针对图片进行处理的(哈哈哈),因此现在的思路就变成了,我还需要将绘制出来的文本页面转换成图片,再作为资源输入给 ImageShader 组件进行使用。

我查了Skia的相关文档后,发现了一个叫 useTexture 的 Hook。这个 Hook 接受一个 React 元素并输出图像作为组件资产进行使用。以下是官网的相关代码示例:

import { useWindowDimensions } from "react-native";
import { useTexture } from "@shopify/react-native-skia";
import { Image, Rect, rect, Canvas, Fill } from "@shopify/react-native-skia";
import React from "react";

const Demo = () => {
  const { width, height } = useWindowDimensions();
  const texture = useTexture(<Fill color="cyan" />, { width, height });

  return (
    <Canvas style={{ flex: 1 }}>
      <Image image={texture} rect={{ x: 0, y: 0, width, height }} />
    </Canvas>
  );
};

然而,绘制出来的界面却有点模糊不清。虽然我是一个菜鸟切图仔,但我的直觉告诉我!这绝壁和屏幕像素比有不可告人的关系。于是,我又在github找相关的资料传送门,最终找到了解决方案:在指定画布宽高时,乘以设备的像素密度(dp),并在最后的组件中进行一次反缩放,以适应像素比,从而就解决了模糊问题(哈哈哈,我真是个天才,谁说切图仔没前途的,我偏要做个切图仔)。

绘制文字图片的代码如下:

import { useWindowDimensions } from "react-native";
import { useTexture } from "@shopify/react-native-skia";
import { Image, Rect, rect, Canvas, Fill } from "@shopify/react-native-skia";
import React from "react";

const Demo = () => {
  const { width, height } = useWindowDimensions();
  const currentPage = useTexture(
    <Group transform={[{ scale: dp }]}>
      <Fill />
      <Glyphs
        font={font}
        glyphs={
          [
            /* 计算后的本页文字布局 */
          ]
        }
      />
    </Group>,
    { width: width * dp, height: height * dp },
  );

  return (
    <Canvas style={{ width, height }}>
         <Image image={currentPage} width={width} height={height} />
      </Canvas>
  );
};

处理翻页效果中的锯齿问题

(其实也没处理,我直接换了个着色器哈哈)

锯齿的问题示例,注意右下角:

e51259b7f5ce96dbf5b56d48fadeb66.jpg

现在,文字组件已经可以正常绘制了,并转换成了图片资源。从而可以正常输入给着色器组件。然后在检查实现的翻页动画效果时,我发现这个着色器在过渡过程中存在问题,滑动越多次,边缘出现了锯齿状的像素并且越来越严重。这个初版的着色器代码属实是有点复杂,我直接就开始找别的着色器实现(学会放弃也许才是进步的开始[狗头])。然后我又找到了现在用的这个着色器“simple page curl”的 GLSL 代码,然后我就开始尝试将其迁移到我的demo中,迁移过程虽然有点复杂,但这里就不展开了(懒)。

在调试过程中,我又又又发现了一个 bug:虽然卷曲效果是正确的,但渲染中出现了反射性区域。如下图所示:

4dc3a906a1cacaec10be2cb9b136f85.jpg

前面也说到了,这个代码叫做“simple page curl”,重点就是“simple”,实现的篇幅比较短,代码里面也贴了一篇实现时记录的参考文章,所以对照阅读文章后,我就开始尝试理解代码,看看问题出在哪里。通过阅读代码,我又用了二分之一注释大法,逐步定位到下问题所在。具体来说,在判断当前滚轴位置时,滚轴线的右边的区域应该直接复用下一页对应位置的像素颜色,而不应进行额外的颜色处理(除了需要应用阴影效果的调色)。所以问题也就出现在这里,然后我就开始尝试修复,以下是原代码(可以不看,代码不会详细讲,可以到里面的链接跟着理解一下),以及修复后的代码片段:

// Name: SimplePageCurl
// Author: Andrew Hung
// License: MIT
// Adapted by Raymond Luckhurst
// see simple page curl effect by Andrew Hung, https://www.shadertoy.com/view/ls3cDB
// and https://andrewhungblog.wordpress.com/2018/04/29/page-curl-shader-breakdown/

const float M_PI = 3.14159265359;

uniform int angle; // = 80
uniform float radius; // = 0.1
uniform bool roll; // = false
uniform bool uncurl; // = false
uniform bool greyback; // = false
uniform float opacity; // = 0.8
uniform float shadow; // = 0.2

vec4 transition (vec2 uv) {
    // setup
    float phi = radians(float(angle)) - M_PI / 2.; // target curl angle
    vec2 dir = normalize(vec2(cos(phi) * ratio, sin(phi))); // direction unit vector
    vec2 q = vec2((dir.x >= 0.) ? 0.5 : -0.5, (dir.y >= 0.) ? 0.5 : -0.5); // quadrant corner
    vec2 i = dir * dot(q, dir); // initial position, curl axis on corner
    vec2 f = -(i + dir * radius * 2.); // final position, curl & shadow just out of view
    vec2 m = f - i; // path extent, perpendicular to curl axis

    // get point relative to curl axis
    vec2 p = i + m * (uncurl ? 1. - progress : progress); // current axis point from origin
    q = uv - .5; // distance of current point from centre
    float dist = dot(q - p, dir); // distance of point from curl axis
    p = q - dir * dist; // point perpendicular to curl axis

    // map point to curl
    vec4 a = getFromColor(uv), b = getToColor(uv), c = uncurl ? a : b;
    bool g = false, o = false, s = false; // getcolor & opacity & shadow flags
    if (dist < 0.) { // point is over flat or rolling A
        if (!roll) {
            p += dir * (M_PI * radius - dist) + .5;
            g = true;
        } else if (-dist < radius) { // possibly on roll over
            phi = asin(-dist / radius);
            p += dir * (M_PI + phi) * radius + .5;
            g = s = true;
        }
        if (g && p.x >= 0. && p.x <= 1. && p.y >= 0. && p.y <= 1.) // on back of A
            o = true;
        else
            c = uncurl ? b : a, g = false;
    } else if (radius > 0.) { // point is over curling A or flat B
        // map to cylinder point
        phi = asin(dist / radius);
        vec2 p2 = p + dir * (M_PI - phi) * radius + .5;
        vec2 p1 = p + dir * phi * radius + .5;
        if (p2.x >= 0. && p2.x <= 1. && p2.y >= 0. && p2.y <= 1.) // on curling back of A
            p = p2, g = o = s = true;
        else if (p1.x >= 0. && p1.x <= 1. && p1.y >= 0. && p1.y <= 1.) // on curling front of A
            p = p1, g = true;
        else // on B
            s = true;
    }
    if (g) // on A
        c = uncurl ? getToColor(p) : getFromColor(p);
    if (o) {
        if (greyback)
            c.rgb = vec3((c.r + c.b + c.g) / 3.);
        c.rgb += (1. - c.rgb) * opacity;
    }
    if (s && radius > 0.) // TODO: ok over A, makes a tideline over B for large radius
        c.rgb *= pow(clamp(abs(dist + (g ? radius : -radius)) / radius, 0., 1.), shadow);
    return c;
}
// 修复后的代码片段
// 名称: SimplePageCurl
// 作者: Andrew Hung
// 许可证: MIT
// 由 Raymond Luckhurst 修改
// 参考自: https://www.shadertoy.com/view/ls3cDB
//        https://andrewhungblog.wordpress.com/2018/04/29/page-curl-shader-breakdown/
  

const float M_PI = 3.14159265359;
const float CURL_RADIUS = 0.1;
const bool ENABLE_ROLL = true;
const bool ENABLE_UNCURL = false;
const bool USE_GREY_BACK = true;
const float OPACITY = 0.2;
const float SHADOW_INTENSITY = 0.2;

// 新增 uniform 变量
uniform vec2 touchPoint;      // 触摸点坐标,范围 [0,1]
uniform float curlAngle;      // 卷曲角度,默认 80


vec4 transition(vec2 uv) {
    float screenRatio = resolution[0] / resolution[1];
    
    float calculatedAngle = curlAngle;
    
    float phi = radians(calculatedAngle) - M_PI / 2.;
    vec2 dir = normalize(vec2(cos(phi) * screenRatio, sin(phi)));
    
    vec2 quadrant = vec2((dir.x >= 0.) ? 0.5 : -0.5, (dir.y >= 0.) ? 0.5 : -0.5);
    vec2 initialPos = dir * dot(quadrant, dir);
    vec2 finalPos = -(initialPos + dir * CURL_RADIUS * 2.);
    vec2 moveRange = finalPos - initialPos;


    vec2 currentPos = initialPos + moveRange * (ENABLE_UNCURL ? 1. - progress : progress);
    vec2 adjustedUV = uv - 0.5;
    float distance = dot(adjustedUV - currentPos, dir);
    vec2 projectedPos = adjustedUV - dir * distance;

    vec4 fromColor = getFromColor(uv);
    vec4 toColor = getToColor(uv);
    vec4 resultColor = ENABLE_UNCURL ? fromColor : toColor;
    bool needNewColor = false;
    bool needOpacity = false;
    bool needShadow = false;

    // 这里处理卷轴线的左边
    if (distance < 0.) {
        if (!ENABLE_ROLL) {
            projectedPos += dir * (M_PI * CURL_RADIUS - distance) + 0.5;
            needNewColor = true;
        } else if (-distance < CURL_RADIUS) {
            phi = asin(-distance / CURL_RADIUS);
            projectedPos += dir * (M_PI + phi) * CURL_RADIUS + 0.5;
            needNewColor = true;
            needShadow = true;
        }

        if (needNewColor && projectedPos.x >= 0. && projectedPos.x <= 1. && 
            projectedPos.y >= 0. && projectedPos.y <= 1.) {
            needOpacity = true;
        } else {
            resultColor = ENABLE_UNCURL ? toColor : fromColor;
            needNewColor = false;
        }
        // 那么这里处理的就是卷轴的右边
    } else if (CURL_RADIUS > 0.) {
        phi = asin(distance / CURL_RADIUS);
        vec2 backPos = projectedPos + dir * (M_PI - phi) * CURL_RADIUS + 0.5;
        vec2 frontPos = projectedPos + dir * phi * CURL_RADIUS + 0.5;
        
        if (backPos.x >= 0. && backPos.x <= 1. && backPos.y >= 0. && backPos.y <= 1.) {
          // 问题就在这里,源代码一把梭的全部标记了needNewColor的标记为true,所以这里新增判断与卷轴线的距离是否小于卷的曲度,如果小于,则需要重新计算颜色,否则直接复用下一页对应位置的像素颜色
            if (distance < CURL_RADIUS) {
                projectedPos = backPos;
                needNewColor = true;
                needOpacity = true;
            }
            needShadow = true;
        } else if (frontPos.x >= 0. && frontPos.x <= 1. && frontPos.y >= 0. && frontPos.y <= 1.) {
            projectedPos = frontPos;
            needNewColor = true;
        } else {
            needShadow = true;
        }
    }

    if (needNewColor) {
        resultColor = ENABLE_UNCURL ? getToColor(projectedPos) : getFromColor(projectedPos);
    }

    if (needOpacity) {
        if (USE_GREY_BACK) {
            resultColor.rgb = vec3((resultColor.r + resultColor.b + resultColor.g) / 3.);
        }
        resultColor.rgb += (1. - resultColor.rgb) * OPACITY;
    }

    if (needShadow && CURL_RADIUS > 0.) {
        resultColor.rgb *= pow(clamp(abs(distance + (needNewColor ? CURL_RADIUS : -CURL_RADIUS)) / 
                             CURL_RADIUS, 0., 1.), SHADOW_INTENSITY);
    }

    return resultColor;
}

简单优化一下

经过以上步骤,翻页效果基本实现。但是呢,实现动画后,是不是需要进一步优化呢?

  1. 使用 useTexture hooks来在在组件代码执行时进行转换操作是不是太死板了,能不能更灵活一点?
  2. 章节数据是不是应该有更好的方法来进行管理?

针对第二个问题,我单独写了章节类来统一管理章节数据,并接入章节渲染类来存储(计算后的,但是可能不是最优的,毕竟简单考虑一下,比如一个章节很长,是不是要一次性全局计算出来,目前这里还没有做限制,如果有人有更好的解决方案,欢迎评论区留言)渲染布局。最后,我实现了一个 page-manager 类来管理其中的三个图像(当前页、下一页、上一页)获取。这也是我对第一个问题的解决方案。

以下是 page-manager 类的伪代码示例:

// page-manager 类的伪代码

// 创建画笔
const paint = Skia.Paint();

// 绘制文字的核心函数
const drawGlyphs = (canvas: SkCanvas, glyphs: number[], positions: SkPoint[], font: SkFont) => {
  'worklet';
  // 设置背景色
  canvas.clear(Skia.Color('#999'));
  // 设置文字颜色
  paint.setColor(Skia.Color('#fff'));
  // 绘制文字
  canvas.drawGlyphs(glyphs, positions, 0, 0, font, paint);
}

export class PageImageManager {
  private readonly config: PageImageConfig;
  private readonly font: SkFont;
  private readonly surface: ReturnType<typeof Skia.Surface.Make>;
  private readonly canvas: SkCanvas;
  public imageCache: Map<string, SkImage>;
  
  constructor(config: PageImageConfig, font: SkFont) {
    this.config = config;
    this.font = font;
    this.imageCache = new Map();

    // 创建离屏渲染表面
    const surface = Skia.Surface.Make(
      config.width * config.pixelDensity,
      config.height * config.pixelDensity
    );
    if (!surface) {
      throw new Error("无法创建渲染表面");
    }
    
    this.surface = surface;
    this.canvas = surface.getCanvas();
    // 根据像素密度缩放画布
    this.canvas.scale(config.pixelDensity, config.pixelDensity);
  }

  // 生成页面图像
  public generatePageImage(glyphs: number[], positions: SkPoint[]): SkImage {
    drawGlyphs(this.canvas, glyphs, positions, this.font);
    this.surface.flush();
    return this.surface.makeImageSnapshot();
  }

  // 清理资源
  public dispose(): void {
    this.surface.dispose();
    this.imageCache.clear();
  }
} 

现在,组件只需传入当前章节序号和绘制信息,其他由page-manager类进行内部处理。同时,我在类里面增加预缓存功能,以提前绘制当前页面左右的内容并存储到内存中,目前的实现基本就到这里了(哈哈哈,其实还有很大的优化空间)。

Todo

接下来,我的待办事项包括:

  1. 根据触摸点的移动来改变翻页角度,以提高效果。
  2. 增加章节切换时的动画效果。(滑动/滚动/覆盖...)
  3. 当前只是针对文本的翻页,计划应该实现更复杂的页面内容渲染。(如果内容中存在图片/视频/音频...,怎么处理?)
  4. 规范测试当前方案的性能,看是否存在潜在问题。

作为一个第一次接触的小白,其中很多概念和原理我也是一知半解,可能会有理解上的偏差,希望大家多多包涵。目前代码还没有开源,有兴趣想了解的找我哈(主要还是代码写的太烂了,不想丢人)。

最后贴图晒一下昨天吃的肉夹馍哈哈

7c332964758589b5197c68b919d5d78.jpg