Flutter图片浏览器,酷炫?摆烂!

5,188 阅读7分钟

效果图

效果一:任意widget

drag_widget.gif

效果二:视频

drag_video.gif

效果三:图片浏览器,酷炫?摆烂!

drag_browser.gif


前言

前段时间组团摸鱼时,那个谁在闲聊中提出个做不做随意的需求:用户查看个人头像,能不能给加个酷炫的手势动画退出!别人都有,我们也要。

GG.png

其实这样的效果,pub上也有很多优秀的插件。秉着能自己折腾就自己折腾的原则,笔者简单实现了下,再在网上找了资料优化了。后就想着可以自己撸个图片浏览器试试,理想很美好,但现实是酷炫?还是半吊子?往下看。。。

思路

我们把动画解构一下,大致分为3步:

1、点击widget后,执行Hero动画进入查看页。

2、拖拽查看页的widget时,widget的位置跟随手势,且大小缩放;同时查看页背景渐变。

3、在结束手势拖拽时,有两种结果:当拖拽达到预定的有效距离后,执行Hero动画退出;反之则执行动画回到原位置。

实现

有了思路,接下来就简单实现。

项目结构

WeChat1a3ff243dfdf350feb0d23f3f7a8fb41.png

为了更好理解,下面笔者也按照项目结构来逐个说明。

步骤一: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动画返货也要回到原来第三张图片的位置

因为在编译过程中,Herotag就已经确定值了,九宫格中每张图片都有不同的tag,从第一张图片跳转,后续的tag也确定了。为了实现上述的效果,在PageView滑动结束时,就需要更新Herotag值:

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简单处理下就好。


以上就是简单的效果实现过程。基于先跑通业务的原则,没有进行优化,也没有使用状态管理插件提升效果。开发者可以自行处理。

摆烂!!!

废话说完了,接来下进入正题了!

what.webp

作为一个正经的图片浏览器-Image Browser,为啥没有图片手势缩放、拖拽查看的功能!!!

其实单独的图片浏览的功能笔者也实现了,最大的问题其实还是与上述拖拽返回动画相结合后,各种手势的结合、判断、冲突、竞技!!!笔者技术有限,目前确实还存在一些问题,也是因为时间有限(就是懒),到目前为止还没有完整的解决方案。

准备后面有时间再研究下。当然各位大佬们有更好的解决方案,还请不吝赐教!

至于现在,摆烂吧,一起摆烂吧!

GG.jpg


源码

大佬自取。