前语
今天持续探究制作与手势的组合实践,之前在看电子书切换页面时会有一个模仿纸质书本翻页作用,这是典型的制作和手势的结合完成的作用,那么今天咱们就用Flutter也完成这样的一个作用吧。
原理
大家能够找本书翻页到一半看下作用,从右下角翻到一半时,咱们能够将可视区域分为下图A
、B
、C
三部分区域。
A:
下一页可视区域。B:
当时页不可视区域,翻的页不可见的区域。C:
当时页可视区域,也便是需求翻的页的可视区域。
原理分解:
咱们能够先将A
区域和B
区域合为一个区域核算,那么依据途径联合C
区域天然就能够得到,至于A、B
区域区分后面再讲,看下图:a
为手指接触点,表明翻页右下角方位。【已知】f
为固定书本右下角方位。【已知】
a
点和f
已知,衔接af
,咱们令g
点为af
的中点,过g
点衔接eh
笔直af
,为af
中垂线, 可得 g
= Point((a.x + f.x) / 2, (a.y + f.y) / 2);
而且知道△egf
、△emg
、△mfg
为三个直角三角形,由直角三角形类似原理可知这三个三角型两两类似,所以,△emg
类似△mfg
,可知:em/gm = gm/mf;
em = gm*gm/mf;
因为:gm = f.y-g.y;
mf=f.x-g.x;
可得 e
= Point(g.x - (pow(f.y - g.y, 2) / (f.x - g.x)), f.y);
同理过g
点做fh
笔直线可得h
点坐标。略…
从上方理论图可知,cdb
是一条二阶贝塞尔曲线,控制点为e
点, ab
和ak
为直线线段,接下来咱们令n
为ag
的中点,同理过n
点笔直于af
衔接cj
,可知ce
等于ef
的一半;(能够画辅助线过gf
中点笔直af
得出)。
所以可得 c
= Point(e.x - (f.x - e.x) / 2, f.y);
j
点坐标同理。略…
接下来咱们看下b
点,现在咱们已知 a
、e
、c
、j
点坐标,现在b
点便是ae
和cj
的相交点。
那么问题来了:
用咱们九年义务教育学的数学知识解决以下两个问题。
1、在坐标系中,已知两点(x1,y1)、(x2,y2)
坐标,求过这两点直线函数?
2、已知两条直线函数求两条直线的相交点?
咱们知道直线函数表达式为:y=kx+b;,假定k
为正常值,咱们可求得k
和b
的值,
/// 两点求直线方程
static double towPointKb(Point<double> p1, Point<double> p2,
{bool isK = true}) {
/// 求得两点斜率
double k = 0;
double b = 0;
// 避免除数 = 0 出现的核算错误 a e x轴重合
if (p1.x == p2.x) {
// k 为无穷大 函数表达式变为 x= 常量。
k = (p1.y - p2.y) / (p1.x - p2.x-1);
} else {
k = (p1.y - p2.y) / (p1.x - p2.x);
}
b = p1.y - k * p1.x;
if (isK)
return k;
else
return b;
}
经过两条直线表达式的k
值和b
值,咱们就能够求出两条直线是否平行、相交、重合等情况,若相交则可求出。
k
相同b
不同:平行无交点。k
相同b
相同:重合。k
不同无论b相不相同,相交必有一交点。
那么就可得出b
点坐标:(假定k
永不持平)
b = Point((b2 - b1) / (k1 - k2), (b2 - b1) / (k1 - k2) * k1 + b1);
k
点坐标同理。略…
制作
以上AB
区域的关键点现已全部得到了,咱们将辅助线去掉将这些点衔接起来看下作用。
得到AB
区域的一起,咱们直接的就得到了C
区域,
// mPath 为书本矩形区域
Path mPathC = Path.combine(PathOperation.reverseDifference, mPathAB, mPath);
接下来将AB
区域进行区分,再回到上方,坐标图黄色线条部分,咱们能够看到d
点和i
点坐标。
经过原理解析咱们可知d
点为pe
的中点,而p
点为cb
的中点,那么就能够得出:p.x = (e.x -c.x)/2;
,d.x = (e.x-p.x)/2;
p.y = (e.y -b.y)/2;
,d.y = (e.y-p.y)/2;
所以可得 d
= Point(((c.x + b.x) / 2 + e.x) / 2, ((c.y + b.y) / 2 + e.y) / 2);
i
点坐标同理。略…
接下来咱们衔接d
、a
、i
三角形区域,得到以下图形,
同理经过途径联合咱们就能够将AB区域进行分开,
Path mPath1 = Path();
mPath1.moveTo(p.value.d.x, p.value.d.y);
mPath1.lineTo(p.value.a.x, p.value.a.y);
mPath1.lineTo(p.value.i.x, p.value.i.y);
mPath1.close();
Path mPathB = Path.combine(PathOperation.intersect, mPathAB, mPath1);
得到以下图形,
到这儿梳理一下,现在咱们A、B、C
三个path
途径区域现已全部得到,剩余的便是填充书本色彩,接下来咱们将画笔设置为填充不同色彩,经过手势不断改变a
点坐标看下作用。
是不是有点翻书的意思了,这儿有一个问题,书本的左下角也便是c
点坐标在咱们翻页的进程中会跑到页面之外,一般书本都是左边装订,这儿咱们期望到达一个实在的翻页作用就需求将c
点的x
轴最小值设置为书本最左边0
。
这儿涉及到类似图形的数学知识,手指接触点是在不断改变的,当c
点x
轴到达临界值固定的时候,咱们需求从头核算a
点坐标,
见下图,a
是咱们实在的手指触碰的坐标,a1
则为咱们需求核算出来的触碰坐标,从上图可知,△acb类似△a1b1c1
,而且acfd
区域类似a1c1d1f
,那么经过类似原理咱们能够得到fb1/fc1 = fb/fc;
从而得到,fb1= fb * fc1/fc;
,
已知:fb = f.x - a.x;
fc1 = size.width;
fc = f.x-c.x;
同理 fd1/fd = fb1/fb;
得到,fd1 = fb1 * fd/fb;
即可得到a1
点坐标。
核算代码:
double fc = f.x - cx;
double fa = f.x - a.x;
double bb1 = size.width * fa / fc;
double fd1 = f.y - a.y;
double fd = bb1 * fd1 / fa;
a1 = Point(f.x - bb1, f.y - fd);
这时候咱们再来看下作用,
c
点坐标被咱们设定最小值为书本最左边,所以左边不会被翻出区域,看起来更像实在的翻页作用。
增加暗影
咱们能够在灯光下找本书翻页看下暗影作用,差不多是这个姿态,这儿我将暗影分为三个部分,A
区域两个和C
区域一个。
咱们先增加A左区域的暗影,A
左区域的暗影能够认为是从ha
方向由h向a
进行色值突变,所以这儿咱们需求得到A
左暗影区域左上角坐标点,也便是ha
直线向外延伸固定数值的坐标。
能够理解为数学题表达:
已知ha
直线方程式和a
点坐标, 以a
为圆心,画半径为r
(r>0)的圆,
求:此圆和ha
直线的相交的坐标。
设交点为坐标xy
,可得 x²+y² =r²;
y = kx+b;
(k、b 、r)已知,终究咱们得到一个一元二次方程。会解出两个坐标点,这儿咱们只需求往外延伸的坐标点就行,具体能够跟a
点坐标判别得出,之后咱们令double m1 = a.x-p1.x
;double n1 = a.y-p1.y
;
那么暗影外部曲线就能够用下方代码表明。
pyy1.moveTo(p.value.c.x - m1, p.value.c.y);
pyy1.quadraticBezierTo(p.value.e.x - m1, p.value.e.y - n1,
p.value.b.x - m1, p.value.b.y - n1);
pyy1.lineTo(p.value.p.x, p.value.p.y);
pyy1.lineTo(p.value.k.x, p.value.k.y);
pyy1.lineTo(p.value.f.x, p.value.f.y);
pyy1.close();
制作出来看下作用
同理途径联合下:
Path startYY =
Path.combine(PathOperation.reverseDifference, mPathA, pyy1);
得到:
接下来经过设置画笔特点由a
点向p1
点进行突变。
..shader = ui.Gradient.linear(
Offset(p.value.a.x, p.value.a.y),
Offset(p.value.p.x, p.value.p.y),
[Colors.black26, Colors.transparent]
作用:
这儿我设置了由 black26
,向通明突变。延伸长度为10的作用,这儿能够依据半径和色值调整影深。
A
右同理,略…
作用:
接下来咱们制作C区域的暗影,C区域能够看到他是跟eh
是平行的,那么咱们衔接c、j、h、e
点,
// 右下
Path pr = Path();
pr.moveTo(p.value.c.x, p.value.c.y);
pr.lineTo(p.value.j.x, p.value.j.y);
pr.lineTo(p.value.h.x, p.value.h.y);
pr.lineTo(p.value.e.x, p.value.e.y);
pr.close();
得到下面作用:
持续与AB区域进行途径联合,
Path p1 = Path.combine(PathOperation.intersect, pr, mPathAB);
得到下面作用:
持续与B区域再次联合,
Path p2 = Path.combine(PathOperation.difference, p1, mPathB);
终究得到咱们想要的暗影区域。
接下来便是跟A
区域操作一样了,设置线性突变色和突变方向,这儿突变方向的坐标点咱们为u
点和g
点,g
点已知,主要求u点坐标,u
点坐标为af
和di
直线的相交点。
经过两条直线方程求相交点,得到u点今后,设置突变色和突变方向。
中心代码:
// 右下
Path pc = Path();
pc.moveTo(p.value.c.x, p.value.c.y);
pc.lineTo(p.value.j.x, p.value.j.y);
pc.lineTo(p.value.h.x, p.value.h.y);
pc.lineTo(p.value.e.x, p.value.e.y);
pc.close();
Path p1 = Path.combine(PathOperation.intersect, pc, mPathA);
Path p2 = Path.combine(PathOperation.difference, p1, mPathB);
Offset u = Offset(
PaperPoint.toTwoPoint(p.value.a, p.value.f, p.value.d, p.value.i)
.x,
PaperPoint.toTwoPoint(p.value.a, p.value.f, p.value.d, p.value.i)
.y);
canvas.drawPath(
p2,
paint
..style = PaintingStyle.fill
..shader = ui.Gradient.linear(
u, Offset(p.value.g.x,p.value.g.y), [Colors.black26, Colors
.transparent]));
终究得到咱们终究的作用。
这儿暗影部分可能有些瑕疵,特别上方a
点坐标的处理有点生硬,可是没找到好的方法。今后有时间再优化。
翻页动画、回弹动画
意图: 咱们期望能够滑动进程中页码能够自动翻过去,而且误触的情况下不要翻页。
这儿我简略的判别当翻过去书本宽度的3/1
就理解为用户想翻页,当手势松开时自动翻过去;
当翻过去书本宽度小于1/3
,理解为用户误触并不想翻页,当手势松开自动回弹回去。
这儿判别还能够依据用户滑动的速度进行判别,比如按下和松开之间的时间很快而且有想左滑动的间隔,咱们就能够断定用户想要翻页,不过这儿就需求不断的调试优化到达一个比较理想的交互。
初始化动画
回弹动画,咱们期望松开手指时,a
点坐标回到和f
点重合,这儿咱们需求在点击或移动的进程中保存当时手指接触的坐标a
,
var move = d.localPosition;
// 临界值书本以外区域 撤销更新
if (move.dx >= size.width ||
move.dx < 0 ||
move.dy >= size.height ||
move.dy < 0) {
return;
}
currentA = Point(move.dx, move.dy);
...
if ((size.width - move.dx) / size.width > 1 / 3) {
isNext = false;
} else {
isNext = true;
}
然后经过动画将a
点坐标置位f
点;
Point<double> currentA = Point(0, 0);
late AnimationController _controller = AnimationController(
vsync: this, duration: Duration(milliseconds: 800))
..addListener(() {
if (isNext) {
/// 不翻页 回到原始方位
_p.value = PaperPoint(
Point(
currentA.x + (size.width - currentA.x) * _controller.value,
currentA.y + (size.height - currentA.y) * _controller.value,
),
size);
} else {
/// 翻页
_p.value = PaperPoint(
Point(currentA.x - (currentA.x + size.width) * _controller.value,
currentA.y + (size.height - currentA.y) * _controller.value),
size);
}
});
翻页,咱们期望a
点坐标和(-f.x,f.y)
重合,也便是f.x
为负值,相当也咱们书本完全翻过去,
这儿需求注意的是当a.x<0
时,也便是书本左边外面区域,这儿需求将咱们之前设定c
值的最小值放开,不然无法完全翻过去。
只要a.x>0才限制cx坐标点
if (a.x > 0) {
if (cx <= 0) {
// // 临界点
double fc = f.x - cx;
double fa = f.x - a.x;
double bb1 = size.width * fa / fc;
double fd1 = f.y - a.y;
double fd = bb1 * fd1 / fa;
a = Point(f.x - bb1, f.y - fd);
g = Point((a.x + f.x) / 2, (a.y + f.y) / 2);
e = Point(g.x - (pow((f - g).y, 2) / (f - g).x), f.y);
cx = 0;
}
}
ok,有了这些数据今后,咱们看下作用。
填充内容
终究一步,填充内容,模仿书本嘛,当然不能是这些纯色翻页了,上面咱们有了A B C
三个途径的区域,接下来就需求对书本内容Widget
进行裁剪,这儿咱们需求途径裁剪类ClipPath
类,
// 裁剪的途径区域 默认组件的矩形区域
final CustomClipper<Path>? clipper;
const ClipPath({
Key? key,
this.clipper,
this.clipBehavior = Clip.antiAlias,
Widget? child,
}) : assert(clipBehavior != null),
super(key: key, child: child);
能够看到构造里有三个参数,除了子组件,clipBehavior
是裁剪方法,能够设置抗锯齿等,clipper
则是咱们的中心裁剪方法,需求完成CustomClipper
类里的Path getClip(Size size);
方法。
经过它回来一个Path
途径,即可将child
进行自定义裁剪。
ok, 有了方法,接下来咱们开端完成,首先咱们将之前A
区域的Path
途径拿出来,裁剪当时页,经过Stack
帧布局加载当时页和下一页内容,下一页内容永远在第一页内容下面,当翻过去动画完毕时将下方页置位当时页,刷新第二页数据。
翻页动画完毕当时页index+1
;
if (status == AnimationStatus.completed) {
if (!isNext) {
setState(() {
currentIndex++;
});
}
}
填充内容布局代码:
// 定义电子书数据
List<String> dataList = [
"第一页数据",
"第二页数据",
"第三页数据",
];
GestureDetector(
child: Stack(
children: [
currentIndex == dataList.length - 1
? SizedBox()
// 下一页
: ClipPath(
child: Container(
alignment: Alignment.center,
color: Colors.blue,
width: size.width,
height: size.height,
child: Text(
dataList[currentIndex + 1],
style: TextStyle(fontSize: 20),
),
),
),
// // 当时页
ClipPath(
child: Container(
alignment: Alignment.center,
width: size.width,
height: size.height,
color: Colors.blue,
child: Text(
dataList[currentIndex],
style: TextStyle(fontSize: 20),
),
),
clipper: CurrentPaperClipPath(_p),
),
// 最上面只制作B区域和暗影
CustomPaint(
size: size,
painter: _BookPainter(
_p,
),
),
],
),
onPanDown: (d) {
if (currentIndex == dataList.length - 1) {
ToastUtil.show("终究一页了");
return;
}
isNext = false;
var down = d.localPosition;
_p.value = PaperPoint(Point(down.dx, down.dy), size);
currentA = Point(down.dx, down.dy);
},
onPanUpdate: currentIndex == dataList.length - 1
? null
: (d) {
var move = d.localPosition;
// 临界值撤销更新
if (move.dx >= size.width ||
move.dx < 0 ||
move.dy >= size.height ||
move.dy < 0) {
return;
}
currentA = Point(move.dx, move.dy);
_p.value = PaperPoint(Point(move.dx, move.dy), size);
if ((size.width - move.dx) / size.width > 1 / 3) {
isNext = false;
} else {
isNext = true;
}
},
onPanEnd: currentIndex == dataList.length - 1
? null
: (d) {
_controller.forward(
from: 0,
);
},
),
/// 当时页区域
class CurrentPaperClipPath extends CustomClipper<Path> {
ValueNotifier<PaperPoint> p;
CurrentPaperClipPath(
this.p,
) : super(reclip: p);
@override
Path getClip(Size size) {
///书本区域
Path mPath = Path();
mPath.addRect(Rect.fromCenter(
center: Offset(size.width / 2, size.height / 2),
width: size.width,
height: size.height));
Path mPathA = Path();
if (p.value.a != p.value.f && p.value.a.x > -size.width) {
print("当时页 ${p.value.a} ${p.value.f}");
mPathA.moveTo(p.value.c.x, p.value.c.y);
mPathA.quadraticBezierTo(
p.value.e.x, p.value.e.y, p.value.b.x, p.value.b.y);
mPathA.lineTo(p.value.a.x, p.value.a.y);
mPathA.lineTo(p.value.k.x, p.value.k.y);
mPathA.quadraticBezierTo(
p.value.h.x, p.value.h.y, p.value.j.x, p.value.j.y);
mPathA.lineTo(p.value.f.x, p.value.f.y);
mPathA.close();
Path mPathC =
Path.combine(PathOperation.reverseDifference, mPathA, mPath);
return mPathC;
}
return mPath;
}
@override
bool shouldReclip(covariant CurrentPaperClipPath oldClipper) {
return p != oldClipper.p;
}
}
终究看下作用.
回来上一页
上面只要翻页,没有回来上一页,其实回来上一页也很简略,上面咱们完成了回弹动画,这儿只需求修改当时a
点坐标为为书本左边外面,之后调用回弹动画,当时页面-1即可。十分简略。
ElevatedButton(
onPressed: () {
setState(() {
// 表明从页面左边外面开端回弹
currentA = Point(-100, size.height - 100);
currentIndex--;
// 回弹动画
isNext = false;
});
// _p.value = PaperPoint(currentA, size);
_controller.forward(
from: 0,
);
},
child: Text("上一页"))
下面再看下终究作用:
这儿示例只是简略的填充了一个Text
文本,更多内容也是能够的,究竟裁剪的是个Widget。
总结
翻页示例能够说是手势和制作的典型结合,完成进程中也是踩了许多的坑,网上找了很多材料,而且完成原理上也用到了一些初中数学知识,总的来说,进程还是比较弯曲的,本篇文章主要讲了我在完成的进程中的一个具体进程及思路,代码现在先不传了,究竟现在还是有些小问题,后续有时间或许会将他优化下,做成一个开源组件,ok,那本篇文章到这儿就完毕了,期望对你有所帮助~
我正在参加技术社区创作者签约方案招募活动,点击链接报名投稿。