Flutter 也能做复杂效果,影院选座走一波!

吃猫猫的鱼 | 作者

https://juejin.cn/post/6950461776337043492 | 原文

导语

Hi,这里是承香墨影!

接到了一个仿电影院的需求,上周几乎是找遍了百度、谷歌、stackoverflow。均没有找到用 Flutter 实现的效果,那只能自己写一个了。

本文只讲思路,具体实现还需各位看官自己动手,只要看懂了下面的思路,实现起来非常简单。

先看效果图:

竖屏:

横屏:

初始化自适应屏幕的放大缩小效果:

布局分析

  1. 中间的座位 => 矩阵,通过 Column 嵌套 Row 实现,不能通过 GridView 实现(滑动冲突,下文会说明);

  2. 左侧导航条 => 一个简单的 Column(不能用 ListView,同样会造成滑动冲突);

交互分析 & 实现

放大缩小拖动效果

对于放大缩小拖动的效果,Flutter 现在有自带的组件 InteractiveViewer。

  • InteractiveViewer:
    https://juejin.cn/post/6859185139402932238

通过这个组件可完美实现放大缩小效果。组件属性这边不展开解释,比较简单,可点击上面链接自行了解。

这里讲下两个重点属性:

1. 回调事件

  1. 交互开始 onInteractionStart()

  2. 交互更新 onInteractionUpdate()

  3. 交互结束 onInteractionEnd()

2. 变换控制器 transformationController

可以通过这个类来通过代码控制放大缩小效果。

导航条跟随座位表放大缩小拖动

左边导航条跟随中间座位的放大缩小,以及行数定位不偏离

上面讲的那些东西一般大家都能想到,也很好实现。这个交互效果的真正难点是这个跟随滑动效果

由于左边的导航条,是固定在最左侧的,而座位表可以全屏拖动,所以这座位表和导航条,不能放在一个缩放组件里,不然座位表放大的时候,直接将导航条放大出屏幕了。

所以我们的思路,就是将导航条和座位表作为 Stack 的子组件,然后座位表实现放大缩小效果,并且让导航条能跟随座位表进行放大缩小。

笔者在这试了很多方法。

方法一:

左侧导航栏和中间座位表均使用 InteractiveViewer,然后通过 InteractiveViewer 的回调事件和变换控器,来实现效果同步。

结论:

失败transformationController 的原理是 Matrix4 泛型的 ValueNotifier(四维矩阵),简单的移动放大还能实现,完全克隆一个放大缩小拖动效果,笔者做不到。各位如果线性代数非常牛逼的可以试试。

方法二:

flutter 有一个同步滚动组件叫 linked_scroll_controller。他能将两个 scrollController 绑定在一起,实现同步滚动。

  • linked_scroll_controller:
    https://pub.dev/packages/linked_scroll_controller

所以让左侧导航栏使用 ListView, 中间座位表使用 InteractiveViewer 嵌套 GridView, 然后将 ListView 和 GridView 的 ScrollController 绑定在一起实现同步滚动。

结论:

失败,InteractiveViewer 的滑动是通过 Matrix4 实现的,和 ListView 的滑动冲突。同步滚动实现了,但是放大缩小的拖动无法执行。

方法三:

使用 InteractiveViewer 是逃不过的,不然自己实现放大缩小效果太头疼,如果能像上面的 linked_scroll_controller 一样,将 InteractiveViewer 的缩放效果复制到另外一个 InteractiveViewer 中去,那就完美了。

就是「方法一」的思路,但是用 InteractiveViewer 开放的接口和控制器,无法完成,这个时候就需要去阅读理解 InteractiveViewer 的源码,看看有没有什么启发。

@override
Widget build(BuildContext context) {
  Widget child = Transform(
    transform: _transformationController.value,
    child: KeyedSubtree(
      key: _childKey,
      child: widget.child,
    ),
  );

  if (!widget.constrained) {
    child = OverflowBox(
      alignment: Alignment.topLeft,
      minWidth: 0.0,
      minHeight: 0.0,
      maxWidth: double.infinity,
      maxHeight: double.infinity,
      // maxHeight: 220.w,
      child: child,
    );
  }

  if (widget.clipBehavior != Clip.none) {
    child = ClipRRect(
      clipBehavior: widget.clipBehavior,
      child: child,
    );
  }

  // A GestureDetector allows the detection of panning and zooming gestures on
  // the child.
  return Listener(
    key: _parentKey,
    onPointerSignal: _receivedPointerSignal,
    child: GestureDetector(
      behavior: HitTestBehavior.opaque,
      // Necessary when panning off screen.
      dragStartBehavior: DragStartBehavior.start,
      onScaleEnd: onScaleEnd,
      onScaleStart: onScaleStart,
      onScaleUpdate: onScaleUpdate,
      child: child,
    ),
  );
}

不看不知道,一看吓一跳,其实 InteractiveViewer 已经将所有的方法都替我们封装好了。

注意上面的 GestureDetector,整个 InteractiveViewer 的手势交互方法,其实就是 onScaleEnd()onScaleStart()onScaleUpdate() 这三个方法。

那只需要将座位表组件回调的这三个方法中的参数,传入到导航条组件中去就行,然后删掉导航条组件的 GestureDetector,让导航条组件只接受来自座位表组件的手势交互参数。

只需重写两个 InteractiveViewer,一个为主组件(座位表),一个为从组件(导航条),并开放 InteractiveViewerState,当座位表组件回调手势的三个方法时,通过 key 将三个方法的参数传入导航条组件就 OK。

_onInteractionUpdate(ScaleUpdateDetails details) {
  if (controller.fromInteractiveViewKey.currentState != null) {
    controller.fromInteractiveViewKey.currentState.onScaleUpdate(details);
  }
}

_onInteractionStart(ScaleStartDetails details) {
  if (controller.fromInteractiveViewKey.currentState != null) {
    controller.fromInteractiveViewKey.currentState.onScaleStart(details);
  }
}

_onInteractionEnd(ScaleEndDetails details) {
  if (controller.fromInteractiveViewKey.currentState != null) {
    controller.fromInteractiveViewKey.currentState.onScaleEnd(details);
  }
}

完全无需任何加工,将参数照搬照抄的传入导航条组件。我们就能实现同步缩放拖动的效果!

这里必须特别注意:座位表和导航条组件的单个 item 的高度必须完全相同,包括 marginpadding,不然还是会出现错位现象。

至此,最大的难点同步缩放和滑动就解决了。

底部弹框悬浮在座位表上方

点击座位后弹出底部弹框,遮盖部分座位表,但是座位表能持续向上拖动显示完最后一行的数据。

这个乍一看没啥难的,但细细一想也有点复杂。

首先,明确座位表的显示区域是包含底部弹框的,因为底部弹框是悬浮在座位表上面的,

那么我们就只能使用 margin 而不是 padding,所以根据设计图底部弹框的 height,我们将 marginBottom 设成这个 height 就行,但是会有个问题:

当整个座位表放大 margin 部分也会同步放大,这样就会导致放的越大,座位表距离下面空出的间距就越大。

解决思路:

我们需要拿到当前放大的倍数,动态调整 margin,当前放大 X 倍,原始 margin 为 Y,则当前放大后的 margin=Y/X

Y 已知,我们只需要知道 X 就行。

但是在 _onInteractionUpdate 接口中,X 并非当前放大几倍,而是较上次缩放后的缩放倍数。

即:初始 1.0 倍,第一次放大至 2 倍,接口回调的放大倍数为 2。第二次放大至 3 倍,接口回调的放大倍数为 1.5(较第一次又放大了 1.5 倍)。

并且更严重的是当放大到 maxScale 后,接口仍会持续回调放大倍数。这就很困扰我们,

后来阅读源码后发现,我们所要的较原始放大倍数的当前放大倍数参数在 InteractiveViewer 类中的。

// Return a new matrix representing the given matrix after applying the given
// scale.
Matrix4 _matrixScale(Matrix4 matrix, double scale) {
  if (scale == 1.0) {
    return matrix.clone();
  }
  assert(scale != 0.0);

  // Don't allow a scale that results in an overall scale beyond min/max
  // scale.
  final double currentScale =
      _transformationController.value.getMaxScaleOnAxis();
  final double totalScale =currentScale * scale;
  //改了算法
  // final double totalScale = math.max(
  //   currentScale * scale,
  //   // Ensure that the scale cannot make the child so big that it can't fit
  //   // inside the boundaries (in either direction).
  //   math.max(
  //     _viewport.width / _boundaryRect.width,
  //     _viewport.height / _boundaryRect.height,
  //   ),
  // );
  final double clampedTotalScale = totalScale.clamp(
    widget.minScale,
    widget.maxScale,
  );

  widget.scaleCallback?.call(clampedTotalScale);

  final double clampedScale = clampedTotalScale / currentScale;
  return matrix.clone()..scale(clampedScale);
}

注意上面的 scaleCallback,这是笔者自己实现的回调方法,其中的 clampedTotalScale 就是我们想要的较初始缩放倍数的当前放大倍数,即:初始 1.0 倍。

第一次放大至 2 倍,接口回调的放大倍数为 2;

第二次放大至 3 倍,接口回调的放大倍数为 3(较初始放大了 3 倍)。

clampedTotalScale 永远在 minScalemaxScale 的区间内。拿来即用非常方便。

上面代码中有一段算法被我注释掉了,这段代码的效果是:

当 InteractiveViewer 中的 child 已经完全显示的时候,则无法再缩小,即 minScale 不仅仅取决于我们设置的值, 还取决于 InteractiveViewer 的 child 显示效果,这里我不需要这个限制,则将他注释掉了。

其实如果要完美实现 UI 给出的效果,有很多地方要用到 margin, 比如座位表的上下左右 margin, 只要拿到了上面的 clampedTotalScale,均可以动态计算,很方便。

横竖屏适配效果

上面的 gif 图有横屏效果,横竖屏切换用的也是官方 API,OrientationBuilder,这个用起来也很简单。这里讲一个 UI 适配的注意事项:

由于笔者项目用了 ScreenUtil(UI 自适应),所以在竖屏的时候,传入竖屏的 UI 尺寸图,且尺寸结尾使用 .w 进行适配,当横屏时,传入横屏的 UI 尺寸图(其实就是将竖屏的 width 和 height 倒置),然后尺寸结尾使用 .h 进行适配。这样就基本能完美适配横竖屏,剩余的细节就可以微调。

推荐开源的 Flutter UI 适配库,像素级还原 UI 设计:screen_autosize

初始放大倍数

如上面的效果图, 在第一次进入或横竖屏切换时,当座位表布局过多(默认显示不下时),尽可能缩小以显示更多的内容(下限缩小至 minScale),当座位表布局过少(默认显示时屏幕很空),尽可能放大直至显示满屏幕(上限放大至 maxScale)。

上面效果可总结为:在尽可能显示完全的前提下尽可能大

InteractiveViewer 并没有初始放大倍数参数,默认进入都是放大 1.0 倍。

这里就需要我们自己来算出这个初始放大倍数。

计算

如果有用 screenUtil,以下计算注意区分横竖屏, 横屏时适配结尾用 .w, 竖屏用 .h, 其中异形屏的 padding 不用区分横竖屏,系统会自动更改。

1. 整个座位表的显示区域

  • 屏幕高 - 异形屏上下 padding - 竖屏时底部悬浮框的 height(横屏悬浮框如果不在底部,则为 0)- 标题栏高度以及自己加的一些其他布局的高度;

  • 屏幕宽 - 异形屏左右 padding - 横屏时右侧悬浮框 width(竖屏时悬浮框如不在右侧,则为 0)- 导航条宽度(这个导航栏宽度也需要根据放大缩小倍数动态计算)- 其他自己加的布局宽;

2. 算初始放大倍数(1.0)下的座位表 item 的 width 和 height 以及 padding, 这个直接按设计图的就行;

3. 获得当前座位表的 x 轴和 y 轴。即每行几个座位,一共有几行座位;

4. 计算假设要将所有座位表显示下,每个 item 的 width 和 height;

即用上面 1. 所得的座位表显示区域的宽高分别除以座位表的 x 和 y。

5. 计算 SX & SY;

将 2.width 除以 4.width,即如 X 轴完全显示下需要缩放的值 SX。

将 2.height 除以 4.height,即如 Y 轴完全显示下需要缩放的值 SY。

6. 比较 SX 和 SY 两值,取小值 defaultS(在尽可能显示完全的前提下尽可能大);

7. 如果 defaultS 在 minScale 和 maxScale 区间内,则取 defaultS,反之取区间边界值;

缩放

transformationController 将 InteractiveViewer 缩放到 defaultS

// 座位表
mainTransformationController.value = Matrix4.identity()..scale(defaultS);
// 导航条
fromTransformationController.value = Matrix4.identity()..scale(defaultS);

这里注意座位表和导航条都要进行缩放。

缩放动态 margin

最后别忘记将各种需要动态计算的 margin 也缩放到 defaultS 值。

如果有横竖屏切换效果的,在每次横竖屏切换的时候都动态计算初始放大值,

需要注意,每次计算的时候都要将动态计算的 margin 置为初始值(即当缩放大小为 1.0 时的 margin 值)。

结语

至此所有效果都实现了,剩余的相信大家自己也能搞定,非常的简单。

有时候想不出来就看源码,立马就会醍醐灌顶。

-- End --

本文对你有帮助吗?留言、转发、点好看是最大的支持,谢谢!

推荐阅读:

动态生成代码:AOP 之 AspectJ 在 Android 的应用!

面试官装x失败之:Activity的启动模式

Flutter仿Dribbble的扫描闹钟酷炫效果 | 自绘实现!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值