Flutter 小技巧之霓虹灯文本的「故障」效果的实现

Posted 恋猫de小郭

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Flutter 小技巧之霓虹灯文本的「故障」效果的实现相关的知识,希望对你有一定的参考价值。

如下图所示,最近通过群友的问题在 codepen.io 上看到了一个文本「抽动」的动画实现,看起来就像是生活中常见的「霓虹灯招牌」故障时的「抽动」效果,而本篇的目标通过「抄袭」这个实现,帮助大家理解 Flutter 里的一些实现小技巧。

这个效果在 codepen 上是通过 CSS 实现的,实现思路 codepen 上的 Glitch Walkthrough 大致有提示,但是 Flutter 没有强大的 CSS,那么如何将它「复刻」到 Flutter 上就是本篇的核心要点。

不得不说 CSS 很强大,要在 Flutter 上实现类似的效果还是比较「折腾」。

而要在 Flutter 上实现类似 Glitch Walkthrough 的效果,大致上我们需要处理:

  • 类似霓虹灯效果的文本
  • 文本内容撕裂的效果
  • 文本变形闪动的效果

那么接下来我们就按照这个流程来实现一个 Flutter 上的 Glitch Walkthrough 。

霓虹灯文本

这一步其实相对简单,Flutter 的 TextStyle 提供了 shadows 配置,通过它可以快速实现一个「会发光」的文本。

我们这里通过两个 Shadow 来实现「发光」的视觉效果,核心就是利用 ShadowblurRadius 来让背景出现一定程度的模糊发散,然后两个 Shadow 形成不一样的颜色深度和发散效果,从而达到看起来「发亮」的效果。

如下图是没有填充文本颜色时 Shadow 的效果。

最后,如下代码所示,我们只需要通过 foreground 给文本补充下颜色,就可以看到如下图所示的类似「霓虹灯」效果的文本。

当然这里你不想用 foreground ,只用简单的 color 也可以。

Text(
  widget.text,
  style: TextStyle(
    fontSize: 48,
    fontWeight: FontWeight.bold,
    foreground: Paint()
      ..style = PaintingStyle.fill
      ..strokeWidth = 5
      ..color = Colors.white,
    shadows: [
      Shadow(
        blurRadius: 10,
        color: Colors.white,
        offset: Offset(0, 0),
      ),
      Shadow(
        blurRadius: 20,
        color: Colors.white30,
        offset: Offset(0, 0),
      ),
    ],
  ),
)

这里提个题外话,其实类似的思路用在图片上也可以实现「发光」的效果,如下代码所示,通过 Stack 嵌套两个 Image ,然后中间通过 BackdropFilterImageFilter 做一层模糊,让底下的图片模糊后发散产生类似「发光」的效果。

 var child = Image.asset(
   'static/test_logo.png',
   width: 250,
 );
 return Stack(
      children: [
        child,
        Positioned.fill(
          child: BackdropFilter(
            filter: ImageFilter.blur(
              sigmaX: blurRadius,
              sigmaY: blurRadius,
            ),
            child: Container(color: Colors.transparent),
          ),
        ),
        child,
      ],
    )
 );

如下图所示,图片最终可以通过自己的色彩产生类似「发光」的效果,当然这部分只是额外的拓展内容,和我们要实现的效果无关。

文本撕裂

这部分可以说是需求效果的核心,这里我们需要用到 ClipPathPolygon ,通过 Polygon 来实现随机的多边形路径,然后利用 ClipPath 对文本内容进行随机的路径裁剪。

虽然说用 Polygon , 但是 Flutter 官方并没有直接提供类似前端 CSS 的 Polygon 多边形 API 支持,但是社区总有「好心人」,我们可以直接使用 Flutter 上类似的第三方库: polygon: ^0.1.0

简单说 Polygon 就是按照 step 对 PathmoveToquadraticBezierTo 等 API 进行了封装。

Flutter 上的 Polygon 取值范围是 -1 ~ 1 ,也就是按照比例决定位置,比如 - 1 就是起始点, 1 就是最大宽高, 更具体如下面的代码所示,这里利用 Polygon 添加了三个点,最终这三个点形成的 Path 会绘制出一个三角形。

List<Offset> generatePoint() 
  List<Offset> points = [];
  points.add(Offset(-1, -1));
  points.add(Offset(-1, 0));
  points.add(Offset(0, -1));
  return points;

如下代码所示,那如果如果 point 的数量多了,就可以形成一系列不规则的形状,比如下面代码随机添加了 60 个点的位置,可以看到此时屏幕上的白色 Container 被裁剪成「凌乱」的形状。

List<Offset> generatePoint() 
  List<Offset> points = [];

  points.add(Offset(-1.00, -0.76));
  points.add(Offset(0.06, -0.76));
  points.add(Offset(0.06, -0.48));
  points.add(Offset(-0.50, -0.48));
  points.add(Offset(-0.50, 0.72));
  points.add(Offset(-0.38, 0.72));
  points.add(Offset(-0.38, -1.00));
  points.add(Offset(0.06, -1.00));
  points.add(Offset(0.06, 0.67));
  points.add(Offset(0.84, 0.67));
  points.add(Offset(0.84, 0.63));
  points.add(Offset(0.39, 0.63));
  points.add(Offset(0.39, -0.42));
  points.add(Offset(0.56, -0.42));
  points.add(Offset(0.56, 0.30));
  points.add(Offset(0.37, 0.30));
  points.add(Offset(0.37, 0.32));
  points.add(Offset(0.54, 0.32));
  points.add(Offset(0.54, -0.09));
  points.add(Offset(0.70, -0.09));
  points.add(Offset(0.70, -0.48));
  points.add(Offset(0.94, -0.48));
  points.add(Offset(0.94, -0.43));
  points.add(Offset(0.67, -0.43));
  points.add(Offset(0.67, -0.31));
  points.add(Offset(0.08, -0.31));
  points.add(Offset(0.08, 0.78));
  points.add(Offset(-0.40, 0.78));
  points.add(Offset(-0.40, 0.15));
  points.add(Offset(0.65, 0.15));
  points.add(Offset(0.65, 0.00));
  points.add(Offset(0.36, 0.00));
  points.add(Offset(0.36, -0.28));
  points.add(Offset(0.24, -0.28));
  points.add(Offset(0.24, -0.80));
  points.add(Offset(-0.76, -0.80));
  points.add(Offset(-0.76, -0.31));
  points.add(Offset(0.19, -0.31));
  points.add(Offset(0.19, 0.13));
  points.add(Offset(0.96, 0.13));
  points.add(Offset(0.96, 0.65));
  points.add(Offset(-0.80, 0.65));
  points.add(Offset(-0.80, 0.06));
  points.add(Offset(0.82, 0.06));
  points.add(Offset(0.82, 0.67));
  points.add(Offset(0.60, 0.67));
  points.add(Offset(0.60, 0.65));
  points.add(Offset(-0.19, 0.65));
  return points;

如果这时候把白色 Container 换成文本内容,那么我们就可以如下图所示的效果,看起来像不像一帧状态下文本的「错乱」效果?后面我们只需要每次生成一帧这样的 Path ,就可以实现文本动态「撕裂」的需求。

我们只需要把这个实现做成随机输出,然后每次生成一个 Path 就可以了。

如下代码所示,我们通过 generatePoint 方法,每次随机生成 60 个点,然后将这些点通过 computePath 转化为 Path,然后继承 CustomClipper 配置到 getClip 方法里,在需要的时候(tear )对 child 按 Path 进行裁剪。

注意这里的 i % 2 ,为的是让上次的 x 或者 y 可以是同一个位置,在连接上能连续。

class RandomTearingClipper extends CustomClipper<Path> 
  bool tear;

  RandomTearingClipper(this.tear);

  List<Offset> generatePoint() 
    List<Offset> points = [];
    var x = -1.0;
    var y = -1.0;
    for (var i = 0; i < 60; i++) 
      if (i % 2 != 0) 
        x = Random().nextDouble() * (Random().nextBool() ? -1 : 1);
       else 
        y = Random().nextDouble() * (Random().nextBool() ? -1 : 1);
      
      points.add(Offset(x, y));
    
    return points;
  

  
  Path getClip(Size size) 
    var points = generatePoint();
    var polygon = Polygon(points);
    if (tear)
      return polygon.computePath(rect: Offset.zero & size);
    else
      return Path()..addRect(Offset.zero & size);
  

  
  bool shouldReclip(RandomTearingClipper oldClipper) => true;

接着,我们只需要设置一个定期器,然后将前面的「霓虹灯文本」和「故障裁剪效果」配置到 ClipPath 上,如下图所示,我们就可以看到文本的随机撕裂效果。

timer = Timer.periodic(Duration(milliseconds: 400), (timer) 
  tearFunction();
);

return ClipPath(
   child: Center(
     child: Text(
       widget.text,
       style: TextStyle(
         fontSize: 48,
         fontWeight: FontWeight.bold,
         foreground: Paint()
           ..style = PaintingStyle.fill
           ..strokeWidth = 1
           ..color = Colors.white,
         shadows: [
           Shadow(
             blurRadius: 10,
             color: Colors.white,
             offset: Offset(0, 0),
           ),
           Shadow(
             blurRadius: 20,
             color: Colors.white30,
             offset: Offset(0, 0),
           ),
         ],
       ),
     ),
   ),
   clipper: RandomTearingClipper(tear),
 );

此时看起来还不够形象。

变形闪动

为了达到我们预期的效果,最后我们还需要做一些特殊处理,比如再实现两个形状、颜色和位置不一样「霓虹灯文本」,为的就是实现「变形和闪动」的效果替换。

比如如下代码所示,通过 ShaderMask 可以实现一个渐变效果的的文本,这是用来在闪动的时候,提供一个短暂替换和色彩加深的作用。

ShaderMask(
  blendMode: BlendMode.srcATop,
  shaderCallback: (bounds) 
    return LinearGradient(
      colors: [Colors.blue, Colors.green, Colors.red],
      stops: [0.0, 0.5, 1.0],
    ).createShader(bounds);
  ,
  child:

类似的我们还可以实现一个「变形」的文本,在之前的白色「霓虹灯」文本基础上增加「斜体」和「颜色变淡」等处理,用来闪动的时候提供「变形」的作用。

最后我们再将之前的 ClipPath添加到它们上面,并增加一个 transform 实现文本四周随意移动的效果支持,如下图所示,此时的效果已经肉眼可见的接近我们的需求。

transform:
    Matrix4.translationValues(randomPosition(4), randomPosition(4), 0),

double randomPosition(position) 
  return Random().nextInt(position).toDouble() *
      (Random().nextBool() ? -1 : 1);

最后我们将这几个文本效果用 Stack 组合起来,然后再在定时器里不停去切换「故障」和「正常」的文本状态,并且随机选择展示不同的 「故障」状态。

timer = Timer.periodic(Duration(milliseconds: 400), (timer) 
  tearFunction();
);
timer2 = Timer.periodic(Duration(milliseconds: 600), (timer) 
  tearFunction();
);

tearFunction() 
  count++;
  tear = count % 2 == 0;
  if (tear == true) 
    setState(() );
    Future.delayed(Duration(milliseconds: 150), () 
      setState(() 
        tear = false;
      );
    );
  



Widget build(BuildContext context) 
  var status = Random().nextInt(3);
  return Stack(
    children: [
      if (tear && (status == 1)) renderTearText1(RandomTearingClipper(tear)),
      if (!tear || (tear && status != 2))
        renderMainText(RandomTearingClipper(tear)),
      if (tear && status == 2) renderTearText2(RandomTearingClipper(tear)),
    ],
  );

最终效果如下图所示,这里还额外对后面两个文本做了一个 ClipRect 处理,闪动切换的时候只展示部分内容,这样在「故障」时的切换不会显得太过生硬,可以看到简单的 CSS 效

Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套

这次的 Flutter 小技巧是 ListViewPageView 的花式嵌套,不同 Scrollable 的嵌套冲突问题相信大家不会陌生,今天就通过 ListViewPageView 的三种嵌套模式带大家收获一些不一样的小技巧。

正常嵌套

最常见的嵌套应该就是横向 PageView 加纵向 ListView 的组合,一般情况下这个组合不会有什么问题,除非你硬是要斜着滑

最近刚好遇到好几个人同时在问:“斜滑 ListView 容易切换到 PageView 滑动” 的问题,如下 GIF 所示,当用户在滑动 ListView 时,滑动角度带上倾斜之后,可能就会导致滑动的是 PageView 而不是 ListView

 虽然从我个人体验上并不觉得这是个问题,但是如果产品硬是要你修改,难道要自己重写 PageView 的手势响应吗?

我们简单看一下,不管是 PageView 还是 ListView 它们的滑动效果都来自于 Scrollable ,而 Scrollable 内部针对不同方向的响应,是通过 RawGestureDetector 完成:

  • VerticalDragGestureRecognizer 处理垂直方向的手势
  • HorizontalDragGestureRecognizer 处理水平方向的手势

所以简单看它们响应的判断逻辑,可以看到一个很有趣的方法 computeHitSlop根据 pointer 的类型确定当然命中需要的最小像素,触摸默认是 kTouchSlop (18.0)

看到这你有没有灵光一闪:如果我们把 PageView 的 touchSlop 修改了,是不是就可以调整它响应的灵敏度? 恰好在 computeHitSlop 方法里,它可以通过 DeviceGestureSettings 来配置,而 DeviceGestureSettings 来自于 MediaQuery ,所以如下代码所示:

body: MediaQuery(
  ///调高 touchSlop 到 50 ,这样 pageview 滑动可能有点点影响,
  ///但是大概率处理了斜着滑动触发的问题
  data: MediaQuery.of(context).copyWith(
      gestureSettings: DeviceGestureSettings(
    touchSlop: 50,
  )),
  child: PageView(
    scrollDirection: Axis.horizontal,
    pageSnapping: true,
    children: [
      HandlerListView(),
      HandlerListView(),
    ],
  ),
),
复制代码

小技巧一:通过嵌套一个 MediaQuery ,然后调整 gestureSettingstouchSlop 从而修改 PageView 的灵明度 ,另外不要忘记,还需要把 ListViewtouchSlop 切换会默认 的 kTouchSlop

class HandlerListView extends StatefulWidget 
  @override
  _MyListViewState createState() => _MyListViewState();

class _MyListViewState extends State<HandlerListView> 
  @override
  Widget build(BuildContext context) 
    return MediaQuery(
      ///这里 touchSlop  需要调回默认
      data: MediaQuery.of(context).copyWith(
          gestureSettings: DeviceGestureSettings(
        touchSlop: kTouchSlop,
      )),
      child: ListView.separated(
        itemCount: 15,
        itemBuilder: (context, index) 
          return ListTile(
            title: Text('Item $index'),
          );
        ,
        separatorBuilder: (context, index) 
          return const Divider(
            thickness: 3,
          );
        ,
      ),
    );
  

复制代码

最后我们看一下效果,如下 GIF 所示,现在就算你斜着滑动,也很触发 PageView 的水平滑动,只有横向移动时才会触发 PageView 的手势,当然, 如果要说这个粗暴的写法有什么问题的话,大概就是降低了 PageView 响应的灵敏度

 

同方向 PageView 嵌套 ListView

介绍完常规使用,接着来点不一样的,在垂直切换的 PageView 里嵌套垂直滚动的 ListView , 你第一感觉是不是觉得不靠谱,为什么会有这样的场景?

对于产品来说,他们不会考虑你如何实现的问题,他们只会拍着脑袋说淘宝可以,为什么你不行,所以如果是你,你会怎么做?

而关于这个需求,社区目前讨论的结果是:PageViewListView 的滑动禁用,然后通过 RawGestureDetector 自己管理

如果对实现逻辑分析没兴趣,可以直接看本小节末尾的 源码链接 

看到自己管理先不要慌,虽然要自己实现 PageViewListView 的手势分发,但是其实并不需要重写 PageViewListView ,我们可以复用它们的 Darg 响应逻辑,如下代码所示:

  • 通过 NeverScrollableScrollPhysics 禁止了 PageViewListView 的滚动效果
  • 通过顶部 RawGestureDetector VerticalDragGestureRecognizer 自己管理手势事件
  • 配置 PageControllerScrollController 用于获取状态
body: RawGestureDetector(
  gestures: <Type, GestureRecognizerFactory>
    VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
            VerticalDragGestureRecognizer>(
        () => VerticalDragGestureRecognizer(),
        (VerticalDragGestureRecognizer instance) 
      instance
        ..onStart = _handleDragStart
        ..onUpdate = _handleDragUpdate
        ..onEnd = _handleDragEnd
        ..onCancel = _handleDragCancel;
    )
  ,
  behavior: HitTestBehavior.opaque,
  child: PageView(
    controller: _pageController,
    scrollDirection: Axis.vertical,
    ///屏蔽默认的滑动响应
    physics: const NeverScrollableScrollPhysics(),
    children: [
      ListView.builder(
        controller: _listScrollController,
        ///屏蔽默认的滑动响应
        physics: const NeverScrollableScrollPhysics(),
        itemBuilder: (context, index) 
          return ListTile(title: Text('List Item $index'));
        ,
        itemCount: 30,
      ),
      Container(
        color: Colors.green,
        child: Center(
          child: Text(
            'Page View',
            style: TextStyle(fontSize: 50),
          ),
        ),
      )
    ],
  ),
),

接着我们看 _handleDragStart 实现,如下代码所示,在产生手势 details 时,我们主要判断:

  • 通过 ScrollController 判断 ListView 是否可见
  • 判断触摸位置是否在 ListIView 范围内
  • 根据状态判断通过哪个 Controller 去生产 Drag 对象,用于响应后续的滑动事件

  void _handleDragStart(DragStartDetails details) 
    ///先判断 Listview 是否可见或者可以调用
    ///一般不可见时 hasClients false ,因为 PageView 也没有 keepAlive
    if (_listScrollController?.hasClients == true &&
        _listScrollController?.position.context.storageContext != null) 
      ///获取 ListView 的  renderBox
      final RenderBox? renderBox = _listScrollController
          ?.position.context.storageContext
          .findRenderObject() as RenderBox;

      ///判断触摸的位置是否在 ListView 内
      ///不在范围内一般是因为 ListView 已经滑动上去了,坐标位置和触摸位置不一致
      if (renderBox?.paintBounds
              .shift(renderBox.localToGlobal(Offset.zero))
              .contains(details.globalPosition) ==
          true) 
        _activeScrollController = _listScrollController;
        _drag = _activeScrollController?.position.drag(details, _disposeDrag);
        return;
      
    

    ///这时候就可以认为是 PageView 需要滑动
    _activeScrollController = _pageController;
    _drag = _pageController?.position.drag(details, _disposeDrag);
  

前面我们主要在触摸开始时,判断需要响应的对象时 ListView 还是 PageView ,然后通过 _activeScrollController 保存当然响应对象,并且通过 Controller 生成用于响应手势信息的 Drag 对象。

简单说:滑动事件发生时,默认会建立一个 Drag 用于处理后续的滑动事件,Drag 会对原始事件进行加工之后再给到 ScrollPosition 去触发后续滑动效果。

接着在 _handleDragUpdate 方法里,主要是判断响应是不是需要切换到 PageView :

  • 如果不需要就继续用前面得到的 _drag?.update(details)响应 ListView 滚动
  • 如果需要就通过 _pageController 切换新的 _drag 对象用于响应
void _handleDragUpdate(DragUpdateDetails details) 
  if (_activeScrollController == _listScrollController &&

      ///手指向上移动,也就是快要显示出底部 PageView
      details.primaryDelta! < 0 &&

      ///到了底部,切换到 PageView
      _activeScrollController?.position.pixels ==
          _activeScrollController?.position.maxScrollExtent) 
    ///切换相应的控制器
    _activeScrollController = _pageController;
    _drag?.cancel();

    ///参考  Scrollable 里
    ///因为是切换控制器,也就是要更新 Drag
    ///拖拽流程要切换到 PageView 里,所以需要  DragStartDetails
    ///所以需要把 DragUpdateDetails 变成 DragStartDetails
    ///提取出 PageView 里的 Drag 相应 details
    _drag = _pageController?.position.drag(
        DragStartDetails(
            globalPosition: details.globalPosition,
            localPosition: details.localPosition),
        _disposeDrag);
  
  _drag?.update(details);

这里有个小知识点:如上代码所示,我们可以简单通过 details.primaryDelta 判断滑动方向和移动的是否是主轴

最后如下 GIF 所示,可以看到 PageView 嵌套 ListView 同方向滑动可以正常运行了,但是目前还有个两个小问题,从图示可以看到:

  • 在切换之后 ListView 的位置没有保存下来
  • 产品要求去除 ListView 的边缘溢出效果

 所以我们需要对 ListView 做一个 KeepAlive ,然后用简单的方法去除 Android 边缘滑动的 Material 效果:

  • 通过 with AutomaticKeepAliveClientMixinListView 在切换之后也保持滑动位置
  • 通过 ScrollConfiguration.of(context).copyWith(overscroll: false) 快速去除 Scrollable 的边缘 Material 效果
child: PageView(
  controller: _pageController,
  scrollDirection: Axis.vertical,
  ///去掉 Android 上默认的边缘拖拽效果
  scrollBehavior:
      ScrollConfiguration.of(context).copyWith(overscroll: false),


///对 PageView 里的 ListView 做 KeepAlive 记住位置
class KeepAliveListView extends StatefulWidget 
  final ScrollController? listScrollController;
  final int itemCount;

  KeepAliveListView(
    required this.listScrollController,
    required this.itemCount,
  );

  @override
  KeepAliveListViewState createState() => KeepAliveListViewState();


class KeepAliveListViewState extends State<KeepAliveListView>
    with AutomaticKeepAliveClientMixin 
  @override
  Widget build(BuildContext context) 
    super.build(context);
    return ListView.builder(
      controller: widget.listScrollController,

      ///屏蔽默认的滑动响应
      physics: const NeverScrollableScrollPhysics(),
      itemBuilder: (context, index) 
        return ListTile(title: Text('List Item $index'));
      ,
      itemCount: widget.itemCount,
    );
  

  @override
  bool get wantKeepAlive => true;

所以这里我们有解锁了另外一个小技巧:通过 ScrollConfiguration.of(context).copyWith(overscroll: false) 快速去除 Android 滑动到边缘的 Material 2效果,为什么说 Material2, 因为 Material3 上变了,具体可见: Flutter 3 下的 ThemeExtensions 和 Material3

本小节源码可见: github.com/CarGuo/gsy_…

同方向 ListView 嵌套 PageView

那还有没有更非常规的?答案是肯定的,毕竟产品的小脑袋,怎么会想不到在垂直滑动的 ListView 里嵌套垂直切换的 PageView 这种需求。

有了前面的思路,其实实现这个逻辑也是异曲同工:PageViewListView 的滑动禁用,然后通过 RawGestureDetector 自己管理,不同的就是手势方法分发的差异。

RawGestureDetector(
          gestures: <Type, GestureRecognizerFactory>
            VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
                    VerticalDragGestureRecognizer>(
                () => VerticalDragGestureRecognizer(),
                (VerticalDragGestureRecognizer instance) 
              instance
                ..onStart = _handleDragStart
                ..onUpdate = _handleDragUpdate
                ..onEnd = _handleDragEnd
                ..onCancel = _handleDragCancel;
            )
          ,
          behavior: HitTestBehavior.opaque,
          child: ListView.builder(
                ///屏蔽默认的滑动响应
                physics: NeverScrollableScrollPhysics(),
                controller: _listScrollController,
                itemCount: 5,
                itemBuilder: (context, index) 
                  if (index == 0) 
                    return Container(
                      height: 300,
                      child: KeepAlivePageView(
                        pageController: _pageController,
                        itemCount: itemCount,
                      ),
                    );
                  
                  return Container(
                      height: 300,
                      color: Colors.greenAccent,
                      child: Center(
                        child: Text(
                          "Item $index",
                          style: TextStyle(fontSize: 40, color: Colors.blue),
                        ),
                      ));
                ),
        )

同样是在 _handleDragStart 方法里,这里首先需要判断:

  • ListView 如果已经滑动过,就不响应顶部 PageView 的事件
  • 如果此时 ListView 处于顶部未滑动,判断手势位置是否在 PageView 里,如果是响应 PageView 的事件
  void _handleDragStart(DragStartDetails details) 
    ///只要不是顶部,就不响应 PageView 的滑动
    ///所以这个判断只支持垂直 PageView 在  ListView 的顶部
    if (_listScrollController.offset > 0) 
      _activeScrollController = _listScrollController;
      _drag = _listScrollController.position.drag(details, _disposeDrag);
      return;
    

    ///此时处于  ListView 的顶部
    if (_pageController.hasClients) 
      ///获取 PageView
      final RenderBox renderBox =
          _pageController.position.context.storageContext.findRenderObject()
              as RenderBox;

      ///判断触摸范围是不是在 PageView
      final isDragPageView = renderBox.paintBounds
          .shift(renderBox.localToGlobal(Offset.zero))
          .contains(details.globalPosition);

      ///如果在 PageView 里就切换到 PageView
      if (isDragPageView) 
        _activeScrollController = _pageController;
        _drag = _activeScrollController.position.drag(details, _disposeDrag);
        return;
      
    

    ///不在 PageView 里就继续响应 ListView
    _activeScrollController = _listScrollController;
    _drag = _listScrollController.position.drag(details, _disposeDrag);
  

接着在 _handleDragUpdate 方法里,判断如果 PageView 已经滑动到最后一页,也将滑动事件切换到 ListView

void _handleDragUpdate(DragUpdateDetails details) 
  var scrollDirection = _activeScrollController.position.userScrollDirection;

  ///判断此时响应的如果还是 _pageController,是不是到了最后一页
  if (_activeScrollController == _pageController &&
      scrollDirection == ScrollDirection.reverse &&

      ///是不是到最后一页了,到最后一页就切换回 pageController
      (_pageController.page != null &&
          _pageController.page! >= (itemCount - 1))) 
    ///切换回 ListView
    _activeScrollController = _listScrollController;
    _drag?.cancel();
    _drag = _listScrollController.position.drag(
        DragStartDetails(
            globalPosition: details.globalPosition,
            localPosition: details.localPosition),
        _disposeDrag);
  
  _drag?.update(details);

当然,同样还有 KeepAlive 和去除列表 Material 边缘效果,最后运行效果如下 GIF 所示。

本小节源码可见:github.com/CarGuo/gsy_…

最后再补充一个小技巧:如果你需要 Flutter 打印手势竞技的过程,可以配置 debugPrintGestureArenaDiagnostics = true;来让 Flutter 输出手势竞技的处理过程

import 'package:flutter/gestures.dart';
void main() 
  debugPrintGestureArenaDiagnostics = true;
  runApp(MyApp());

复制代码

最后

最后总结一下,本篇介绍了如何通过 Darg 解决各种因为嵌套而导致的手势冲突,相信大家也知道了如何利用 ControllerDarg 来快速自定义一些滑动需求,例如 ListView 联动 ListView 的差量滑动效果:

///listView 联动 listView
class ListViewLinkListView extends StatefulWidget 
  @override
  _ListViewLinkListViewState createState() => _ListViewLinkListViewState();


class _ListViewLinkListViewState extends State<ListViewLinkListView> 
  ScrollController _primaryScrollController = ScrollController();
  ScrollController _subScrollController = ScrollController();

  Drag? _primaryDrag;
  Drag? _subDrag;

  @override
  void initState() 
    super.initState();
  

  @override
  void dispose() 
    _primaryScrollController.dispose();
    _subScrollController.dispose();
    super.dispose();
  

  void _handleDragStart(DragStartDetails details) 
    _primaryDrag =
        _primaryScrollController.position.drag(details, _disposePrimaryDrag);
    _subDrag = _subScrollController.position.drag(details, _disposeSubDrag);
  

  void _handleDragUpdate(DragUpdateDetails details) 
    _primaryDrag?.update(details);

    ///除以10实现差量效果
    _subDrag?.update(DragUpdateDetails(
        sourceTimeStamp: details.sourceTimeStamp,
        delta: details.delta / 30,
        primaryDelta: (details.primaryDelta ?? 0) / 30,
        globalPosition: details.globalPosition,
        localPosition: details.localPosition));
  

  void _handleDragEnd(DragEndDetails details) 
    _primaryDrag?.end(details);
    _subDrag?.end(details);
  

  void _handleDragCancel() 
    _primaryDrag?.cancel();
    _subDrag?.cancel();
  

  void _disposePrimaryDrag() 
    _primaryDrag = null;
  

  void _disposeSubDrag() 
    _subDrag = null;
  

  @override
  Widget build(BuildContext context) 
    return Scaffold(
        appBar: AppBar(
          title: Text("ListViewLinkListView"),
        ),
        body: RawGestureDetector(
          gestures: <Type, GestureRecognizerFactory>
            VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
                    VerticalDragGestureRecognizer>(
                () => VerticalDragGestureRecognizer(),
                (VerticalDragGestureRecognizer instance) 
              instance
                ..onStart = _handleDragStart
                ..onUpdate = _handleDragUpdate
                ..onEnd = _handleDragEnd
                ..onCancel = _handleDragCancel;
            )
          ,
          behavior: HitTestBehavior.opaque,
          child: ScrollConfiguration(
            ///去掉 Android 上默认的边缘拖拽效果
            behavior:
                ScrollConfiguration.of(context).copyWith(overscroll: false),
            child: Row(
              children: [
                new Expanded(
                    child: ListView.builder(

                        ///屏蔽默认的滑动响应
                        physics: NeverScrollableScrollPhysics(),
                        controller: _primaryScrollController,
                        itemCount: 55,
                        itemBuilder: (context, index) 
                          return Container(
                              height: 300,
                              color: Colors.greenAccent,
                              child: Center(
                                child: Text(
                                  "Item $index",
                                  style: TextStyle(
                                      fontSize: 40, color: Colors.blue),
                                ),
                              ));
                        )),
                new SizedBox(
                  width: 5,
                ),
                new Expanded(
                  child: ListView.builder(

                      ///屏蔽默认的滑动响应
                      physics: NeverScrollableScrollPhysics(),
                      controller: _subScrollController,
                      itemCount: 55,
                      itemBuilder: (context, index) 
                        return Container(
                          height: 300,
                          color: Colors.deepOrange,
                          child: Center(
                            child: Text(
                              "Item $index",
                              style:
                                  TextStyle(fontSize: 40, color: Colors.white),
                            ),
                          ),
                        );
                      ),
                ),
              ],
            ),
          ),
        ));
  

 

以上是关于Flutter 小技巧之霓虹灯文本的「故障」效果的实现的主要内容,如果未能解决你的问题,请参考以下文章

Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套

Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套

如何为小部件边框/阴影添加霓虹发光效果?

Android 帧布局FrameLayout之霓虹灯效果

小5聊使用js+css+div布局方式画一棵圣诞树,带闪烁霓虹灯效果

Flutter 小技巧之 Dart 里的 List 和 Iterable 你真的搞懂了吗?