Flutter 仿抖音项目(改)
项目地址 mjl0602/flutter_tiktok: Flutter tiktok short video app. (github.com)
应用截图
应用功能
- 上下刷视频,同时自动加载封面
- 实现刷新页面
- 左滑右滑实现个人中心
- 双击出现爱心和点赞
- 看评论
- 看关注
- 看收藏
- 底部tabbar的切换
细节
- 适应了不同屏幕比例
项目结构
- 依赖:
# 加载动画库(好像改版之后就没用到了)
flutter_spinkit: ^4.1.2
# Bilibili开源的视频播放组件
fijkplayer: ^0.8.3
# 基础的透明动画点击效果
tapped: any
# map安全取值
safemap: any
- 主要文件
./lib
├── main.dart
├── mock
│ └── video.dart # 假数据
├── other
│ └── bottomSheet.dart # 修改了系统BottomSheet的高度
├── pages
│ ├── cameraPage.dart # 拍摄页(没有实际功能)
│ ├── followPage.dart # 略
│ ├── homePage.dart # 主页面,包含tikTokScaffold的实际应用功能
│ ├── msgDetailListPage.dart # 略
│ ├── msgPage.dart # 略
│ ├── searchPage.dart # 略
│ ├── todoPage.dart # 略
│ ├── userDetailPage.dart # 略
│ ├── userPage.dart # 略
│ └── walletPage.d # 略
├── style
│ ├── style.dart # 全局文字大小与颜色
│ └── text.dart # 主要的几个文字样式
└── views
├── backButton.dart # iOS形状的返回按钮组件
├── loadingButton.dart # 可以设置为载入样式的按钮组件
├── selectText.dart # 可设置为“选中”或者“未选中”样式的文字
├── tikTokCommentBottomSheet.dart # 仿Tiktok评论样式
├── tikTokHeader.dart # 仿Tiktok顶部切换组件
├── tikTokScaffold.dart # 仿Tiktok核心脚手架,封装了手势与切换等功能,本身不包含UI内容
├── tikTokVideo.dart # 仿Tiktok的视频UI样式封装,不包含视频播放
├── tikTokVideoButtonColumn.dart # 仿Tiktok视频右侧的头像与点赞等按钮列的组件
├── tikTokVideoGesture.dart # 仿Tiktok的双击点赞效果
├── tikTokVideoPlayer.dart # 视频播放页面,带有控制滑动的VideoListController类
├── tiktokTabBar.dart # 仿Tiktok的底部Tabbar组件
├── tilTokAppBar.dart # 仿Tiktok的Appbar组件
├── topToolRow.dart # 用户页面的顶部状态,在tab切换到user页面时隐藏返回按钮
└── userMsgRow.dart # 一条用户信息的样式组件
我做出的修改
修改前示意
修改后示意
修改代码路径及内容
lib/views/TikTokButtonColumn.dart
修改内容:修改了点击红心的颜色(右侧的红心,评论,分享侧边栏)
lib/views/TikTokVideoGesture.dart
- 经过测试,原本项目利用点击屏幕的坐标设置为GeatureDector的Key,如果反复点击屏幕的同一位置,会出现Key重复导致Bug,优化了代码结构之后,去除掉了Offset的转化函数,并且使用context.findRenderObject() as RenderBox 转化,
- 优化了项目自带的动画效果,本来的Transform.rotate套一个Transform.scale,实现爱心的旋转和先变小后变大的效果。现在的修改版本,通过爆改函数,实现了类似抖音的动画平移,爱心旋转,爱心缩小后扩大的效果,同时优化了颜色等细节,目前的点赞十分类似抖音原版
(因为个人抖音账号原因,在这里就不放入抖音点赞效果了,可以打开原版抖音看一下)
修改部分示意
- 原本代码(TikTokVideoGesture.dart)
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
/// 视频手势封装
/// 单击:暂停
/// 双击:点赞,双击后再次单击也是增加点赞爱心
class TikTokVideoGesture extends StatefulWidget {
const TikTokVideoGesture({
Key? key,
required this.child,
this.onAddFavorite,
this.onSingleTap,
}) : super(key: key);
final Function? onAddFavorite;
final Function? onSingleTap;
final Widget child;
@override
_TikTokVideoGestureState createState() => _TikTokVideoGestureState();
}
class _TikTokVideoGestureState extends State<TikTokVideoGesture> {
GlobalKey _key = GlobalKey();
// 内部转换坐标点
Offset _p(Offset p) {
RenderBox getBox = _key.currentContext!.findRenderObject() as RenderBox;
return getBox.globalToLocal(p);
}
List<Offset> icons = [];
bool canAddFavorite = false;
bool justAddFavorite = false;
Timer? timer;
@override
Widget build(BuildContext context) {
var iconStack = Stack(
children: icons
.map<Widget>(
(p) => TikTokFavoriteAnimationIcon(
key: Key(p.toString()),
position: p,
onAnimationComplete: () {
icons.remove(p);
},
),
)
.toList(),
);
return GestureDetector(
key: _key,
onTapDown: (detail) {
setState(() {
if (canAddFavorite) {
print('添加爱心,当前爱心数量:${icons.length}');
icons.add(_p(detail.globalPosition));
widget.onAddFavorite?.call();
justAddFavorite = true;
} else {
justAddFavorite = false;
}
});
},
onTapUp: (detail) {
timer?.cancel();
var delay = canAddFavorite ? 1200 : 600;
timer = Timer(Duration(milliseconds: delay), () {
canAddFavorite = false;
timer = null;
if (!justAddFavorite) {
widget.onSingleTap?.call();
}
});
canAddFavorite = true;
},
onTapCancel: () {
print('onTapCancel');
},
child: Stack(
children: <Widget>[
widget.child,
iconStack,
],
),
);
}
}
class TikTokFavoriteAnimationIcon extends StatefulWidget {
final Offset? position;
final double size;
final Function? onAnimationComplete;
const TikTokFavoriteAnimationIcon({
Key? key,
this.onAnimationComplete,
this.position,
this.size: 100,
}) : super(key: key);
@override
_TikTokFavoriteAnimationIconState createState() =>
_TikTokFavoriteAnimationIconState();
}
class _TikTokFavoriteAnimationIconState
extends State<TikTokFavoriteAnimationIcon> with TickerProviderStateMixin {
AnimationController? _animationController;
@override
void dispose() {
_animationController?.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
print('didChangeDependencies');
super.didChangeDependencies();
}
@override
void initState() {
_animationController = AnimationController(
lowerBound: 0,
upperBound: 1,
duration: Duration(milliseconds: 1600),
vsync: this,
);
_animationController!.addListener(() {
setState(() {});
});
startAnimation();
super.initState();
}
startAnimation() async {
await _animationController!.forward();
widget.onAnimationComplete?.call();
}
double rotate = pi / 10.0 * (2 * Random().nextDouble() - 1);
double? get value => _animationController?.value;
double appearDuration = 0.1;
double dismissDuration = 0.8;
double get opa {
if (value! < appearDuration) {
return 0.99 / appearDuration * value!;
}
if (value! < dismissDuration) {
return 0.99;
}
var res = 0.99 - (value! - dismissDuration) / (1 - dismissDuration);
return res < 0 ? 0 : res;
}
double get scale {
if (value! < appearDuration) {
return 1 + appearDuration - value!;
}
if (value! < dismissDuration) {
return 1;
}
return (value! - dismissDuration) / (1 - dismissDuration) + 1;
}
@override
Widget build(BuildContext context) {
Widget content = Icon(
Icons.favorite,
size: widget.size,
color: Colors.redAccent,
);
content = ShaderMask(
child: content,
blendMode: BlendMode.srcATop,
shaderCallback: (Rect bounds) => RadialGradient(
center: Alignment.topLeft.add(Alignment(0.66, 0.66)),
colors: [
Color(0xffEF6F6F),
Color(0xffF03E3E),
],
).createShader(bounds),
);
Widget body = Transform.rotate(
angle: rotate,
child: Opacity(
opacity: opa,
child: Transform.scale(
alignment: Alignment.bottomCenter,
scale: scale,
child: content,
),
),
);
return widget.position == null
? Container()
: Positioned(
left: widget.position!.dx - widget.size / 2,
top: widget.position!.dy - widget.size / 2,
child: body,
);
}
}
- 修改后代码
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
/// 视频手势封装
/// 单击:暂停
/// 双击:点赞,双击后再次单击也是增加点赞爱心
class TikTokVideoGesture extends StatefulWidget {
const TikTokVideoGesture({
Key? key,
required this.child,
this.onAddFavorite,
this.onSingleTap,
}) : super(key: key);
final Function? onAddFavorite;
final Function? onSingleTap;
final Widget child;
@override
_TikTokVideoGestureState createState() => _TikTokVideoGestureState();
}
class _TikTokVideoGestureState extends State<TikTokVideoGesture> {
// 内部转换坐标点
List<Offset> icons = [];
bool canAddFavorite = false;
bool justAddFavorite = false;
Timer? timer;
@override
Widget build(BuildContext context) {
var iconStack = Stack(
children: icons
.map<Widget>(
(p) => TikTokFavoriteAnimationIcon(
key: Key(p.toString()),
position: p,
onAnimationComplete: () {
icons.remove(p);
},
),
)
.toList(),
);
try {
return GestureDetector(
onTapDown: (detail) {
var box = context.findRenderObject() as RenderBox;
var pos = box.globalToLocal(detail.globalPosition);
setState(() {
if (canAddFavorite) {
print('添加爱心,当前爱心数量:${icons.length}');
icons.add(pos);
widget.onAddFavorite?.call();
justAddFavorite = true;
} else {
justAddFavorite = false;
}
});
},
onTapUp: (detail) {
timer?.cancel();
var delay = canAddFavorite ? 1200 : 600;
timer = Timer(Duration(milliseconds: delay), () {
canAddFavorite = false;
timer = null;
if (!justAddFavorite) {
widget.onSingleTap?.call();
}
});
canAddFavorite = true;
},
onTapCancel: () {
print('onTapCancel');
},
child: Stack(
children: <Widget>[
widget.child,
iconStack,
],
),
);
} catch (e) {
print(e);
return Container();
}
}
}
class TikTokFavoriteAnimationIcon extends StatefulWidget {
final Offset? position;
final double size;
final Function? onAnimationComplete;
const TikTokFavoriteAnimationIcon({
Key? key,
this.onAnimationComplete,
this.position,
this.size: 100,
}) : super(key: key);
@override
_TikTokFavoriteAnimationIconState createState() =>
_TikTokFavoriteAnimationIconState();
}
class _TikTokFavoriteAnimationIconState
extends State<TikTokFavoriteAnimationIcon> with TickerProviderStateMixin {
AnimationController? _animationController;
@override
void dispose() {
_animationController?.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
print('didChangeDependencies');
super.didChangeDependencies();
}
@override
void initState() {
_animationController = AnimationController(
lowerBound: 0.75,
upperBound: 1,
duration: Duration(milliseconds: 1500),
vsync: this,
);
_animationController!.addListener(() {
setState(() {});
});
startAnimation();
super.initState();
}
startAnimation() async {
await _animationController!.forward();
widget.onAnimationComplete?.call();
}
double rotate = pi / 10.0 * (2 * Random().nextDouble() - 1);
double? get value => _animationController?.value;
double appearDuration = 0.8;
double dismissDuration = 0.95;
double get opa {
if (value! < appearDuration) {
return 0.99 / appearDuration * value!;
}
if (value! < dismissDuration) {
return 0.99;
}
var res = 0.99 - (value! - dismissDuration) / (1 - dismissDuration);
return res < 0 ? 0 : res;
}
double get scale {
const k = 400;
if (value! < appearDuration) {
var temp1 = k * (value! - 0.775) * (value! - 0.8) + 1;
print(temp1);
return temp1;
}
if (value! < dismissDuration) {
return 1;
}
const axis = 1.05;
var k1 = axis - dismissDuration;
return -(k1) / (value! - axis);
}
double get top {
if (value! < dismissDuration)
return (sqrt(dismissDuration)) * 150 +
(pow(dismissDuration, 3)) * 100 -
50;
return (sqrt(value!)) * 150 + (value! * value! * value!) * 100 - 50;
}
@override
Widget build(BuildContext context) {
Widget content = Icon(
Icons.favorite,
size: widget.size,
color: Colors.redAccent,
);
content = ShaderMask(
child: content,
blendMode: BlendMode.srcATop,
shaderCallback: (Rect bounds) => RadialGradient(
center: Alignment.topLeft.add(Alignment(0.66, 0.66)),
colors: [
Colors.pink.shade200,
Color(0xffE91E63),
],
).createShader(bounds),
);
Widget body = Transform.rotate(
angle: rotate,
child: Opacity(
opacity: opa,
child: Transform.scale(
alignment: Alignment.bottomCenter,
scale: scale,
child: content,
),
),
);
return widget.position == null
? Container()
: Positioned(
left: widget.position!.dx - widget.size / 2,
//top: widget.position!.dy - widget.size / 2,
top: widget.position!.dy - widget.size / 2 + 50 - top,
child: body,
);
}
}
小结
- 代码和commit在我自己电脑里,到时候可以直接展示
- 了解了一点RanderBox的原理
- 学到了一些除去Staggered Animations的组合动画实现方式