Flutter实现Android跑马灯及滚动广告

简介

本文介绍怎么在Flutter里使用ListView实现Android的跑马灯,然后再扩展一下,实现上下滚动。

Github地址

该小控件已经成功上传到pub.dev,安装方式:

dependencies:
   flutterswitcher: ^0.0.1

效果图

先上效果图:

垂直模式
垂直滚动
水平模式
水平滚动

上代码

主要有两种滚动模式,垂直模式和水平模式,所以我们定义两个构造方法。
参数分别有滚动速度(单位是pixels/second)、每次滚动的延迟、滚动的曲线变化和children为空的时候的占位控件。

class Switcher {
  const Switcher.vertical({
    Key key,
    @required this.children,
    this.scrollDelta = _kScrollDelta,
    this.delayedDuration = _kDelayedDuration,
    this.curve = Curves.linearToEaseOut,
    this.placeholder,
  })  : assert(scrollDelta != null && scrollDelta > 0 && scrollDelta <= _kMaxScrollDelta),
        assert(delayDuration != null),
        assert(curve != null),
        spacing = 0,
        _scrollDirection = Axis.vertical,
        super(key: key);
  
  const Switcher.horizontal({
    Key key,
    @required this.children,
    this.scrollDelta = _kScrollDelta,
    this.delayedDuration = _kDelayedDuration,
    this.curve = Curves.linear,
    this.placeholder,
    this.spacing = 10,
  })  : assert(scrollDelta != null && scrollDelta > 0 && scrollDelta <= _kMaxScrollDelta),
        assert(delayDuration != null),
        assert(curve != null),
        assert(spacing != null && spacing >= 0 && spacing < double.infinity),
        _scrollDirection = Axis.horizontal,
        super(key: key);
}

实现思路

实现思路有两种:

  • 第一种是用ListView

  • 第二种是用CustomPaint自己画;

这里我们选择用ListView方式实现,方便后期扩展可手动滚动,如果用CustomPaint,实现起来就比较麻烦。

接下来我们分析一下究竟该怎么实现:

垂直模式

首先分析一下垂直模式,如果想实现循环滚动,那么children的数量就应该比原来的多一个,当滚动到最后一个的时候,立马跳到第一个,这里的最后一个实际就是原来的第一个,所以用户不会有任何察觉,这种实现方式在前端开发中应用很多,比如实现PageView的循环滑动,所以这里我们先定义childCount

_initalizationElements() {
  _childCount = 0;
  if (widget.children != null) {
    _childCount = widget.children.length;
  }
  if (_childCount > 0 && widget._scrollDirection == Axis.vertical) {
    _childCount++;
  }
}

children改变的时候,我们重新计算一次childCount

@override
void didUpdateWidget(Switcher oldWidget) {
  var childrenChanged = (widget.children?.length ?? 0) != (oldWidget.children?.length ?? 0);
  if (widget._scrollDirection != oldWidget._scrollDirection || childrenChanged) {
    _initalizationElements();
    _initializationScroll();
  }
  super.didUpdateWidget(oldWidget);
}

这里判断如果是垂直模式,我们就childCount++,接下来,实现一下build方法:

@override
Widget build(BuildContext context) {
  if (_childCount == 0) {
    return widget.placeholder ?? SizedBox.shrink();
  }
  return LayoutBuilder(
    builder: (context, constraints) {
      return ConstrainedBox(
        constraints: constraints,
        child: ListView.separated(
          itemCount: _childCount,
          physics: NeverScrollableScrollPhysics(),
          controller: _controller,
          scrollDirection: widget._scrollDirection,
          padding: EdgeInsets.zero,
          itemBuilder: (context, index) {
            final child = widget.children[index % widget.children.length];
            return Container(
              alignment: Alignment.centerLeft,
              height: constraints.constrainHeight(),
              child: child,
            );
          },
          separatorBuilder: (context, index) {
            return SizedBox(
              width: widget.spacing,
            );
          },
        ),
      );
    },
  );
}

接下来实现垂直滚动的主要逻辑:

_animateVertical(double extent) {
  if (!_controller.hasClients || widget._scrollDirection != Axis.vertical) {
    return;
  }
  if (_selectedIndex == _childCount - 1) {
    _selectedIndex = 0;
    _controller.jumpTo(0);
  }
  _timer?.cancel();
  _timer = Timer(widget.delayedDuration, () {
    _selectedIndex++;
    var duration = _computeScrollDuration(extent);
    _controller.animateTo(extent * _selectedIndex, duration: duration, curve: widget.curve).whenComplete(() {
      _animateVertical(extent);
    });
  });
}

解释一下这段逻辑,先判断ScrollController有没有加载完成,然后当前的滚动方向是不是垂直的,不是就直接返回,然后当前的index是最后一个的时候,立马跳到第一个,index初始化为0,接下来,取消前一个定时器,开一个新的定时器,定时器的时间为我们传进来的间隔时间,然后每间隔widget.delayedDuration的时间滚动一次,这里调用ScrollController.animateTo,滚动距离为每个item的高度乘以当前的索引,滚动时间根据滚动速度算出来:

Duration _computeScrollDuration(double extent) {
  return Duration(milliseconds: (extent * Duration.millisecondsPerSecond / widget.scrollDelta).floor());
}

这里是我们小学就学过的,距离 = 速度 x 时间,所以根据距离和速度我们就可以得出需要的时间,这里乘以Duration.millisecondsPerSecond的原因是转换成毫秒,因为我们的速度是pixels/second

当完成当前滚动的时候,进行下一次,这里递归调用_animateVertical,这样我们就实现了垂直的循环滚动。

水平模式

接下去实现水平模式,和垂直模式类似:

_animateHorizonal(double extent, bool needsMoveToTop) {
  if (!_controller.hasClients || widget._scrollDirection != Axis.horizontal) {
    return;
  }
  _timer?.cancel();
  _timer = Timer(widget.delayedDuration, () {
    if (needsMoveToTop) {
      _controller.jumpTo(0);
      _animateHorizonal(extent, false);
    } else {
      var duration = _computeScrollDuration(extent);
      _controller.animateTo(extent, duration: duration, curve: widget.curve).whenComplete(() {
        _animateHorizonal(extent, true);
      });
    }
  });
}

这里解释一下needsMoveToTop,因为水平模式下,首尾都要停顿,所以我们加个参数判断下,如果是当前执行的滚动到头部的话,needsMoveToTopfalse,如果是已经滚动到了尾部,needsMoveToToptrue,表示我们的下一次的行为是滚动到头部,而不是开始滚动到整个列表。

接下来我们看看在哪里开始滚动。

首先在页面加载的时候我们开始滚动,然后还有当方向和childCount改变的时候,重新开始滚动,所以:

@override
void initState() {
  super.initState();
  _initalizationElements();
  _initializationScroll();
}

@override
void didUpdateWidget(Switcher oldWidget) {
  var childrenChanged = (widget.children?.length ?? 0) != (oldWidget.children?.length ?? 0);
  if (widget._scrollDirection != oldWidget._scrollDirection || childrenChanged) {
    _initalizationElements();
    _initializationScroll();
  }
  super.didUpdateWidget(oldWidget);
}

然后是_initializationScroll方法:

_initializationScroll() {
  SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
    if (!mounted) {
      return;
    }
    var renderBox = context?.findRenderObject() as RenderBox;
    if (!_controller.hasClients || _childCount == 0 || renderBox == null || !renderBox.hasSize) {
      return;
    }
    var position = _controller.position;
    _timer?.cancel();
    _timer = null;
    position.moveTo(0);
    _selectedIndex = 0;
    if (widget._scrollDirection == Axis.vertical) {
      _animateVertical(renderBox.size.height);
    } else {
      var maxScrollExtent = position.maxScrollExtent;
      _animateHorizonal(maxScrollExtent, false);
    }
  });
}

这里在页面绘制完成的时候,我们判断,如果ScrollController没有加载,childCount == 0或者大小没有计算完成的时候直接返回,然后获取position,取消上一个计时器,然后把列表滚到头部,index初始化为0,判断是垂直模式,开始垂直滚动,如果是水平模式开始水平滚动。

这里注意,垂直滚动的时候,每次的滚动距离是每个item的高度,而水平滚动的时候,滚动距离是列表可滚动的最大长度

到这里我们已经实现了Android的跑马灯,而且还增加了垂直滚动,是不是很简单呢。

如有问题、意见和建议,都可以在评论区里告诉我,我将及时修改和参考你的意见和建议,对代码做出优化。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容