效果图
效果一:任意widget
效果二:视频
效果三:图片浏览器,酷炫?摆烂!
前言
前段时间组团摸鱼时,那个谁在闲聊中提出个做不做随意的需求:用户查看个人头像,能不能给加个酷炫的手势动画退出!别人都有,我们也要。
其实这样的效果,pub上也有很多优秀的插件。秉着能自己折腾就自己折腾的原则,笔者简单实现了下,再在网上找了资料优化了。后就想着可以自己撸个图片浏览器试试,理想很美好,但现实是酷炫?还是半吊子?往下看。。。
思路
我们把动画解构一下,大致分为3步:
1、点击widget后,执行Hero动画进入查看页。
2、拖拽查看页的widget时,widget的位置跟随手势,且大小缩放;同时查看页背景渐变。
3、在结束手势拖拽时,有两种结果:当拖拽达到预定的有效距离后,执行Hero动画退出;反之则执行动画回到原位置。
实现
有了思路,接下来就简单实现。
项目结构
为了更好理解,下面笔者也按照项目结构来逐个说明。
步骤一:Hero
这个动画大家应该都熟悉,我们在这篇文章也做了介绍,简单看下其参数和说明:
| 参数 | 描述 |
|---|---|
| tag | 过渡元素组件的标记值 |
| createRectTween | 位置动画 |
| flightShuttleBuilder | 动画过程组件 |
| placeholderBuilder | 占位符组件 |
| transitionOnUserGestures | 使用手势进行专场时,是否显示动画 |
使用都差不多:
Hero(
tag: "tag",
child: GestureDetector(
onTap: _onTap, // 跳转,执行动画
child: Image.asset("assets/images/yy.png",width: 300,)
),
)
没啥好说的。
步骤二、三
步骤二、三其实可以放一起分析,都是关于手势拖拽的处理。
拖拽的同时会执行三个动画:widget位置变化、widget大小缩放、背景渐变。
- 位置变化:有很多处理思路,可以使用
Align或是Positioned,也可以使用矩阵变换Transform。笔者使用的就是Transform.translate。 - 大小缩放:也有比较多的方法,只要能改变控件的大小都可以。笔者使用的还是矩阵变换
Transform.scale。 - 背景渐变:
Hero动画本身也是路由动画。所有肯定是跳转了路由。而且在拖拽中,会显示上一路由的内容,不用说背景渐变肯定是魔改PageRoute路由的动画了。
notification.dart
首页肯定要使用GestureDetector,拿到手势回调处理。因为GestureDetector存在很多手势变化,而且widget也可能有其他的手势操作,如效果图三的PageView,为了更好的区分,我们使用自定义通知Notification封装一下:
// 开始拖拽
class DragOnPanStartNotification extends Notification {
final DragStartDetails details;
const DragOnPanStartNotification(this.details);
}
// 拖拽更新中
class DragOnPanUpdateNotification extends Notification {
final DragUpdateDetails details;
const DragOnPanUpdateNotification(this.details);
}
// 拖拽结束
class DragOnPanEndNotification extends Notification {
final DragEndDetails details;
const DragOnPanEndNotification(this.details);
}
// 拖拽取消
class DragOnPanCancelNotification extends Notification {
const DragOnPanCancelNotification();
}
// 单击通知,单击直接pop
class DragOnTapNotification extends Notification {
const DragOnTapNotification();
}
通知冒泡来分发通知,通知会沿着当前节点向上传递,所有父节点都可以通过NotificationListener来监听通知。
注意:通知冒泡和用户触摸事件冒泡是相似的,但有一点不同:通知冒泡可以中止,但用户触摸事件不行(NotificationListener中 return true:即中止 )
drag_gesture_detector.dart
再将需要拖拽的widget简单抽取下:
GestureDetector(
onPanStart: (DragStartDetails details){
DragOnPanStartNotification(details).dispatch(context);
onPanStart?.call();
},
onPanUpdate: (DragUpdateDetails details){
DragOnPanUpdateNotification(details).dispatch(context);
onPanUpdate?.call();
},
onPanEnd: (DragEndDetails details){
DragOnPanEndNotification(details).dispatch(context);
onPanEnd?.call();
},
onPanCancel: (){
const DragOnPanCancelNotification().dispatch(context);
onPanCancel?.call();
},
onTap: (){
const DragOnTapNotification().dispatch(context);
onTap?.call();
},
child: child,/// 只有[child]会被响应拖拽事件
)
通过通知冒泡来向上分发手势通知。
drag_gesture_page_route.dart
接下来就是魔改PageRoute。开源就是方便,我们根据MaterialPageRoute源码简单改造下。
这里其实我遇到一个问题,在pop并执行返回Hero的时候,动画结束时老是会闪一下,而且返回的动画也有点异常,很快。看起来就很烦,这点我也有点懵逼。
网上搜了下,有了思路,因为背景渐变使用了路由的AnimationController,同时Hero动画也是基于路由的AnimationController,使默认的动画value和动画时间transitionDuration变化了,导致动画不流畅。
于是采用了大佬的处理方法,使用两个AnimationController,一个控制push,一个控制pop,分开处理:
// 重写动画
@override
AnimationController createAnimationController() {
if (_pushAnimationController == null) {
_pushAnimationController = AnimationController(
vsync: navigator!.overlay!,
duration: transitionDuration,
reverseDuration: transitionDuration,
);
_popAnimationController = AnimationController(
vsync: navigator!.overlay!,
duration: transitionDuration,
reverseDuration: transitionDuration,
value: 1,
);
}
return _pushAnimationController!;
}
这样处理动画确实很丝滑。
再添加背景渐变动画,重写buildTransitions:
// 背景,添加淡入淡出的动画
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return Stack(
children: [
FadeTransition(
// 判断该路由是否在导航上,来使用不同的控制器
opacity: isActive ? _pushAnimationController! : _popAnimationController!,
child: Container(
color: Colors.black, // 背景默认黑色
),
),
child,
],
);
}
最后就是需要动画的路由上的WidgetBuilder内容了。简单抽取封装下:
@override
Widget buildContent(BuildContext context) {
return DragGestureAnimation( // DragGestureAnimation:计算和处理动画的widget
pushAnimationController: _pushAnimationController!,
popAnimationController: _popAnimationController!,
child: builder(context),
);
}
drag_gesture_animation.dart
这里就是把操作动画简单抽取封装了下:
NotificationListener(
onNotification: (notification) {
if (notification is DragOnPanStartNotification) {
_onPanStart(notification.details);
} else if (notification is DragOnPanUpdateNotification) {
_onPanUpdate(notification.details);
} else if (notification is DragOnPanEndNotification) {
_onPanEnd(notification.details);
} else if (notification is DragOnPanCancelNotification) {
_onPanCancel();
}else if (notification is DragOnTapNotification) {
_onTap();
}
return false;
},
child: ValueListenableBuilder<Offset>(
valueListenable: _offsetNotifier,
builder: (context, offset, child) {
return Transform.translate( // 矩阵变换
offset: offset,
child: ValueListenableBuilder<double>(
valueListenable: _scaleNotifier,
builder: (context, scale, child) {
return Transform.scale(
scale: scale,
child: RepaintBoundary(
child: widget.child,
),
);
},
),
);
},
),
)
NotificationListener来监听来自于DragGestureDetector的通知,因为动画更新频繁,使用ValueListenableBuilder来更新状态。
onPanUpdate
拖拽中,更新动画:
// 拖拽中
void _onPanUpdate(DragUpdateDetails details) {
// 更新位置
_offsetNotifier.value += details.delta;
// 更新大小缩放,取滑动距离的绝对值,开发者可自行按需处理
_scaleNotifier.value = 1 - ((_offsetNotifier.value.dy).abs() / centerY * _scaleConst);
// 背景渐变
widget.pushAnimationController.value = 1 - ((_offsetNotifier.value.dy).abs() / centerY );
// pop.value = push.value,动画衔接,更流畅
widget.popAnimationController.value = widget.pushAnimationController.value;
}
onPanEnd
拖拽结束,判断拖拽的距离是否超达到预定的有效距离,我这里设置了100:
// 拖拽的有效距离,达到这个距离,page会直接pop
final double dragY = 100;
达到了就会执行返回;反之则回到原位置:
// 拖拽结束
void _onPanEnd(DragEndDetails details) {
// 上下拖拽距离超过有效距离后,触发退出
if((_offsetNotifier.value.dy).abs() > dragY){
_pop();
}else{ // 不足有效距离,回到原处,开启重置动画
_resetScale = _scaleNotifier.value;
_resetOffset = _offsetNotifier.value;
_resetAnimationBound = widget.pushAnimationController.value;
_resetAnimationController.forward(from: 0);
}
}
这里也添加一个回到原位置的动画,_resetAnimationController。
到这里,简单的拖拽返回的动画就差不多完成了。在此基础上扩展了些不同的效果。
效果一
见文章开头的效果图一。可以扩展至大多数widget,笔者只是拿了Image举例。
效果二
见文章开头的效果图二。
视频播放使用了插件video_player。看起来有点东西,其实很简单,就是在两个路由公用同一个VideoPlayerController就行。
原始页面:
return Scaffold(
appBar: AppBar(title: const Text("视频"),),
body: Hero(
tag: "video",
child: GestureDetector(
onTap: (){
Navigator.push(context, DragGesturePageRoute(builder: (context) {
return VideoPage(videoPlayerController: _videoPlayerController);
},),);
},
child: SizedBox(
width: 300,
child: _videoPlayerController.value.isInitialized ? AspectRatio(
aspectRatio: _videoPlayerController.value.aspectRatio,
child: VideoPlayer(_videoPlayerController),
) : const SizedBox(),
),
),
),
);
跳转后的页面:
class VideoPage extends StatelessWidget {
const VideoPage({Key? key,required this.videoPlayerController}) : super(key: key);
final VideoPlayerController videoPlayerController;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: DragGestureDetector(
child: Hero(
tag: "video",
transitionOnUserGestures: true,
child: AspectRatio(
aspectRatio: videoPlayerController.value.aspectRatio,
child: VideoPlayer(videoPlayerController),
)
),
),
),
);
}
}
效果三
也就是仅仅多了一个PageView而已,但是值得注意的是:点击第一张图片跳转,滑动到第三张图片,Hero动画返货也要回到原来第三张图片的位置。
因为在编译过程中,Hero的tag就已经确定值了,九宫格中每张图片都有不同的tag,从第一张图片跳转,后续的tag也确定了。为了实现上述的效果,在PageView滑动结束时,就需要更新Hero的tag值:
DragGestureDetector(
onPanStart: _onPanStart,
onPanEnd: _onPanEnd,
child: Hero(
tag: widget.tags[_currentPage],
transitionOnUserGestures: true,
child: PageView.builder(
controller: _pageController,
itemCount: widget.tags.length,
itemBuilder: (ctx,index){
return Image.asset(widget.imagePaths[index]);
},
onPageChanged: (page){
if(page != _currentPage){
// 更新tag值
if(mounted){
setState(() {
_currentPage = page;
});
}
}
},
)
),
)
剩下的显示张数和保存的按钮,就使用Visibilit简单处理下就好。
以上就是简单的效果实现过程。基于先跑通业务的原则,没有进行优化,也没有使用状态管理插件提升效果。开发者可以自行处理。
摆烂!!!
废话说完了,接来下进入正题了!
作为一个正经的图片浏览器-Image Browser,为啥没有图片手势缩放、拖拽查看的功能!!!
其实单独的图片浏览的功能笔者也实现了,最大的问题其实还是与上述拖拽返回动画相结合后,各种手势的结合、判断、冲突、竞技!!!笔者技术有限,目前确实还存在一些问题,也是因为时间有限(就是懒),到目前为止还没有完整的解决方案。
准备后面有时间再研究下。当然各位大佬们有更好的解决方案,还请不吝赐教!
至于现在,摆烂吧,一起摆烂吧!
源码
大佬自取。