22 年,我就尝试过实现一个自定义阅读器(现在新开坑了,但是还没有上传github哈),也在掘金发过对应的文章传送门(这是旧的)。虽然当时成功实现了文本的计算绘制和分页切换,但对于所有阅读器都具备的翻页动画,我一直没有找到合适的实现思路。最近,空闲时间我又重新拾起这个项目,第一个重点就是开始研究如何实现这个动画效果。
最终实现效果
实现的过程以及遇到的问题,等我在下面来一一阐述哈。
初步思路
最开始我的分页只是简单是使用组件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 }
);
});
改进后的逻辑代码是:
- 如果用户在左滑/右滑后又反方向滑动,则取消本次翻页,毕竟用户可能后悔了,不想要翻页了。
- 如果本次滑动有效,则将
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 }
);
});
解决图像模糊问题
模糊的图片
在基于旧的代码进行改造下,我使用了原本的文字分页布局和计算逻辑,本地测试代码时,我一开始是直接使用 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>
);
};
处理翻页效果中的锯齿问题
(其实也没处理,我直接换了个着色器哈哈)
锯齿的问题示例,注意右下角:
现在,文字组件已经可以正常绘制了,并转换成了图片资源。从而可以正常输入给着色器组件。然后在检查实现的翻页动画效果时,我发现这个着色器在过渡过程中存在问题,滑动越多次,边缘出现了锯齿状的像素并且越来越严重。这个初版的着色器代码属实是有点复杂,我直接就开始找别的着色器实现(学会放弃也许才是进步的开始[狗头])。然后我又找到了现在用的这个着色器“simple page curl”的 GLSL 代码,然后我就开始尝试将其迁移到我的demo中,迁移过程虽然有点复杂,但这里就不展开了(懒)。
在调试过程中,我又又又发现了一个 bug:虽然卷曲效果是正确的,但渲染中出现了反射性区域。如下图所示:
前面也说到了,这个代码叫做“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;
}
简单优化一下
经过以上步骤,翻页效果基本实现。但是呢,实现动画后,是不是需要进一步优化呢?
- 使用
useTexturehooks来在在组件代码执行时进行转换操作是不是太死板了,能不能更灵活一点? - 章节数据是不是应该有更好的方法来进行管理?
针对第二个问题,我单独写了章节类来统一管理章节数据,并接入章节渲染类来存储(计算后的,但是可能不是最优的,毕竟简单考虑一下,比如一个章节很长,是不是要一次性全局计算出来,目前这里还没有做限制,如果有人有更好的解决方案,欢迎评论区留言)渲染布局。最后,我实现了一个 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
接下来,我的待办事项包括:
- 根据触摸点的移动来改变翻页角度,以提高效果。
- 增加章节切换时的动画效果。(滑动/滚动/覆盖...)
- 当前只是针对文本的翻页,计划应该实现更复杂的页面内容渲染。(如果内容中存在图片/视频/音频...,怎么处理?)
- 规范测试当前方案的性能,看是否存在潜在问题。
作为一个第一次接触的小白,其中很多概念和原理我也是一知半解,可能会有理解上的偏差,希望大家多多包涵。目前代码还没有开源,有兴趣想了解的找我哈(主要还是代码写的太烂了,不想丢人)。
最后贴图晒一下昨天吃的肉夹馍哈哈