用Flutter实现一个通用的流水灯控件

883 阅读2分钟

前言

在布局的过程中,有时候一行无法显示全部内容,而又不想换行时,就需要实现一个流水灯式的控件效果。借助此文所介绍的Marquee控件,可以轻松得给任何控件添加流水灯效果。

使用

例如,要给文字添加流水灯效果,可以这样做:

Marquee(
  child: Text(
    "下面是专辑封面 下面是专辑封面 下面是专辑封面 下面是专辑封面",
    maxLines: 1,
    overflow: TextOverflow.visible,
    style: TextStyle(fontSize: 30),
  ),
),

在一行无法展示所有文字的时候,Marquee中的child会自动开始循环滚动,而且Marquee会根据child的宽度,自动计算滚动的速度,以此符合人的阅读习惯。 当然,Marquee也可以给其他控件添加流水灯效果,比如图片:

Marquee(
  child:Row(
    children: [
      Image.network("https://c-ssl.duitang.com/uploads/item/201601/15/20160115224508_TfuGA.jpeg"),
      SizedBox(width:20),
      Image.network("http://p2.music.126.net/fHyz7zYjnIaUTKoiEkgAbA==/109951164194349109.jpg"),
      SizedBox(width:20),
      Image.network("https://hbimg.huabanimg.com/9758e2248a413d3cc0cb3b05c9447ab51ae0b78c979d6c-Ym5Ehm_fw658/format/webp"),
      SizedBox(width:20),
      Image.network("https://hbimg.huabanimg.com/db2dbd8d46be21c206c11848125d7c7587e9dafa4b71e-LWeH0F_fw658/format/webp"),
    ],
  )
),

运行效果

marquee.gif

原理

先使用CustomMultiChildLayout打破父组件的布局约束,计算好子组件的大小,将子组件的大小信息回调给父组件,以此确定是否启用滚动,控制滚动偏移。借助AnimatedWidget,简化动画部分的代码。借助ClipRect则可以将溢出的部分裁剪掉。

完整代码

github.com/obweix/flut…

import 'package:flutter/material.dart';

class Marquee extends StatefulWidget {
  final Widget child;

  const Marquee({
    required this.child,
    Key? key}) : super(key: key);

  @override
  _MarqueeState createState() => _MarqueeState();
}

class _MarqueeState extends State<Marquee> with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this);

  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MarqueeTransition(
        child: widget.child,
        offset: _controller,
        onScroll: (double childWidth) {
          // 无需滚动时终止动画
          if(0 == childWidth){
            _controller.animateTo(0,duration: Duration(seconds:1));
            return;
          }
          // 优化,防止重复调用repeat函数
          if(_controller.duration == Duration(seconds: childWidth~/50)){
            return;
          }
          _controller.repeat(period: Duration(seconds: childWidth~/50));
        });
  }
}

class MarqueeTransition extends AnimatedWidget{
  final int _childId = 0;

  final Widget child;

  Animation<double> get offset => listenable as Animation<double>;


  /// 获取到[child]的大小后,回调给父类,根据[child]的长度调整滚动速度
  final Function(double width) onScroll;

  const MarqueeTransition({
    Key? key,
    required this.child,
    required Animation<double> offset,
    required this.onScroll,
  }) : assert(offset != null),
        super(key: key, listenable: offset);

  @override
  Widget build(BuildContext context) {
    return ClipRect(
      child: CustomMultiChildLayout(
        delegate: CustomLayout(childId: _childId,scrollOffset:offset.value,onScroll: onScroll),
        children: [
          LayoutId(
            id: _childId,
            child: Row(
              children: [
                child
              ],
            ),
          ),
          LayoutId(
            id: _childId + 1,
            child: Row(
              children: [
                child
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class CustomLayout extends MultiChildLayoutDelegate{
  final int childId;
  final double scrollOffset;
  static const internal = 30;

  /// 获取到[child]的大小后,回调给父类,根据[child]的长度调整滚动速度
  final Function(double width) onScroll;

  CustomLayout(
      {required this.childId,
        required this.scrollOffset,
        required this.onScroll,
      });

  @override
  void performLayout(Size size) {
    if(hasChild(childId)){
      final childSize =  layoutChild(childId, BoxConstraints.loose(Size(double.infinity,size.height)));
      layoutChild(childId + 1, BoxConstraints.loose(Size(double.infinity,size.height)));

      // 文字未溢出,无需滚动
      if(childSize.width < size.width){
        positionChild(childId, Offset.zero);
        onScroll.call(0);
        return;
      }

      // 文字溢出,滚动
      positionChild(childId, Offset(-(childSize.width + internal)*scrollOffset,0));
      positionChild(childId + 1, Offset(-(childSize.width + internal)*scrollOffset + (childSize.width + internal),0));

      // 开启滚动
      onScroll.call(childSize.width);
    }
  }

  @override
  bool shouldRelayout(covariant CustomLayout oldCustomLayout) {
    return oldCustomLayout.scrollOffset != scrollOffset;
  }

}