Flutter-仿騰訊視頻Banner效果

閑聊

人一旦運氣差,喝水都能噎著暂题。我又被發(fā)”畢業(yè)證“了移剪,??,對P秸摺W菘痢!沒有聽錯,發(fā)畢業(yè)證的當(dāng)天上午剛討論完需求攻人,中午吃完飯取试,正常去公司前面的小公園溜達(dá),沒想到怀吻,從天而降的暗器打到我的手臂上瞬浓,我下意識一跺腳,發(fā)出一聲”我靠“蓬坡。原來是一口鳥屎猿棉。心里掐指一算,我這是要中獎屑咳?晚上下班一定買個彩票萨赁。

下午和設(shè)計正討論動畫如何做時,企業(yè)微信收到一個消息乔宿,咳咳位迂,龍哥來xxx會議室下。這時我才明白中午鳥屎的真正含義详瑞。心里發(fā)出一聲”我靠“掂林。從會議室出來才知道我解放了,在一分鐘后坝橡,我才知道整個團(tuán)隊都解放了泻帮。收拾東西就可以走了,這時我才明白中午鳥屎是讓我可以早點下班计寇,才可以趕上買彩票锣杂。要不然正常下班趕不上了。

需求

這兩天看不下去八股文番宁,就想先休息幾天追追劇元莫,打開騰訊視頻看看動漫,發(fā)現(xiàn)騰訊視頻的頂部banner效果滿新穎的蝶押,處于個人愛好踱蠢,就簡單實現(xiàn)了下這個效果,不過我在此基礎(chǔ)上加了一些修改棋电。自己也充當(dāng)一次產(chǎn)品經(jīng)理過把癮茎截,具體效果見效果圖。

效果

tx_banner.gif

實現(xiàn)

因為沒多少代碼赶盔,比較的簡單企锌,幾分鐘搞定,也懶得寫實現(xiàn)原理了于未,直接一把嗦上代碼撕攒。

直接定義一個widget陡鹃,啥玩意都放上。

/// 仿騰訊視頻Banner效果
/// 這里有一些區(qū)別打却,純屬個人覺得這樣更喜歡杉适,所以充當(dāng)了一下產(chǎn)品,修改了需求不同意見莫怪??
/// 區(qū)別1柳击、手勢滑動正經(jīng)半圓效果猿推,騰訊視頻是曲線,這里看個人喜好捌肴。
/// 區(qū)別2蹬叭、左滑和右滑圓弧效果和手勢方向一樣,騰訊視頻是相同方向都向右状知。
/// 區(qū)別3秽五、不管左滑右滑都是切換相同的下一個,騰訊視頻是不同的饥悴。
///
class TxBannerWidget extends StatefulWidget {
  const TxBannerWidget({
    super.key,
    required this.children,
  });

  final List<Widget> children;

  @override
  State<TxBannerWidget> createState() => _TxBannerWidgetState();
}

class _TxBannerWidgetState extends State<TxBannerWidget>
    with TickerProviderStateMixin {
  double _clipFraction = 0.0;
  double _clipFractionCache = 0.0;
  int currentIndex = 0;
  double? scale;
  Timer? _timer;
  late AnimationController _controller;
  late AnimationController _autoController;
  late Animation<double> _animation;

  List<Widget> get bannerWidgets {
    List<Widget> widgetList = [];
    int n = widget.children.length;
    int widgetIndex = currentIndex + n;
    while (widgetIndex >= currentIndex) {
      if (widgetIndex == currentIndex) {
        widgetList.add(
          ClipPath(
            clipper: BannerClipper(clipFraction: _clipFraction),
            child: widget.children[widgetIndex % n],
          ),
        );
      } else {
        widgetList.add(widget.children[widgetIndex % n]);
      }
      widgetIndex--;
    }

    return widgetList.toList();
  }

  @override
  void initState() {
    super.initState();
    // 手勢操作后動畫
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );
    _animation = Tween<double>(begin: 1, end: 0.0).animate(_controller)
      ..addListener(() {
        setState(() {
          if (_clipFractionCache.abs() >= (scale ?? 0.5)) {
            if (_clipFractionCache > 0) {
              _clipFraction = _clipFractionCache +
                  ((1 + (scale ?? 0.5)) - _clipFractionCache) *
                      (1 - _animation.value);
            } else {
              _clipFraction = _clipFractionCache -
                  ((1 + (scale ?? 0.5)) - _clipFractionCache.abs()) *
                      (1 - _animation.value);
            }
          } else {
            _clipFraction = _clipFractionCache * _animation.value;
          }
        });
      })
      ..addStatusListener((status) {
        setState(() {
          if (status == AnimationStatus.completed && _clipFraction.abs() >= 1) {
            _clipFraction = 0.0;
            currentIndex = currentIndex + 1;
          }
        });
      });

    // 自動切換動畫
    _autoController = AnimationController(
      duration: const Duration(milliseconds: 500),
      lowerBound: 0.0,
      upperBound: (1 + (scale ?? 0.5)),
      vsync: this,
    )
      ..addListener(() {
        setState(() {
          _clipFraction = -_autoController.value;
        });
      })
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          _clipFraction = 0.0;
          currentIndex = currentIndex + 1;
        }
      });

    WidgetsBinding.instance.addPostFrameCallback((_) {
      _startAutoTimer();
    });
  }

  /// 開啟自動切換計時器
  void _startAutoTimer() {
    const duration = Duration(seconds: 3);
    _timer = Timer.periodic(duration, (Timer timer) {
      _clipFraction = 0.0;
      _autoController.reset();
      _autoController.forward();
    });
  }

  /// 停止自動切換計時器
  void _stopAutoTimer() {
    _timer?.cancel();
    _timer = null;
  }

  /// 開啟手勢抬起后動畫
  void _startAnimation() {
    _controller.reset();
    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        scale = constraints.maxHeight / constraints.maxWidth;
        return GestureDetector(
          onHorizontalDragDown: (DragDownDetails details) {
            _clipFraction = 0.0;
            _stopAutoTimer();
          },
          onHorizontalDragUpdate: (DragUpdateDetails details) {
            setState(() {
              _clipFraction += details.delta.dx / context.size!.width;
              _clipFractionCache = _clipFraction;
            });
          },
          onHorizontalDragEnd: (DragEndDetails details) {
            _startAnimation();
            _startAutoTimer();
          },
          child: Stack(
            fit: StackFit.expand,
            children: [
              ...bannerWidgets,
              Positioned(
                left: 0,
                right: 0,
                bottom: 10,
                child: PointWidget(
                  index: currentIndex % widget.children.length,
                  max: widget.children.length,
                ),
              )
            ],
          ),
        );
      },
    );
  }
}

/// 自定義裁剪
class BannerClipper extends CustomClipper<Path> {
  final double clipFraction;

  BannerClipper({required this.clipFraction});

  @override
  Path getClip(Size size) {
    Path path = Path();
    if (clipFraction >= 0) {
      double width = size.width * clipFraction;
      path.moveTo(width, 0);
      path.lineTo(size.width, 0);
      path.lineTo(size.width, size.height);
      path.lineTo(width, size.height);
      path.arcTo(
        Rect.fromLTWH(width - size.height, 0, size.height, size.height),
        pi / 2,
        -pi,
        false,
      );
      path.close();
    } else {
      double width = size.width * (1 + clipFraction);
      path.moveTo(0, 0);
      path.lineTo(width, 0);
      path.arcTo(
        Rect.fromLTWH(width, 0, size.height, size.height),
        -pi / 2,
        -pi,
        false,
      );
      path.lineTo(width, size.height);
      path.lineTo(0, size.height);
      path.close();
    }
    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) {
    return true;
  }
}

/// 游標(biāo)widget
class PointWidget extends StatelessWidget {
  const PointWidget({
    Key? key,
    required this.max,
    required this.index,
  }) : super(key: key);

  final int max;
  final int index;

  @override
  Widget build(BuildContext context) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.center,
      mainAxisAlignment: MainAxisAlignment.center,
      mainAxisSize: MainAxisSize.min,
      children: [..._pointsWidget()],
    );
  }

  List<Widget> _pointsWidget() {
    List<Widget> points = [];
    for (int i = 0; i < max; i++) {
      points.add(Container(
        margin: const EdgeInsets.only(left: 4),
        width: 8,
        height: 8,
        decoration: BoxDecoration(
          shape: BoxShape.circle,
          color: index == i ? Colors.blue : Colors.white,
        ),
      ));
    }
    return points;
  }
}

使用

TxBannerWidget(
                  children: [
                    Image.asset(
                      ImageUtils.getImgPath("img1", format: "jpg"),
                      fit: BoxFit.fill,
                    ),
                    Image.asset(
                      ImageUtils.getImgPath("img2", format: "jpg"),
                      fit: BoxFit.fill,
                    ),
                    Image.asset(
                      ImageUtils.getImgPath("img3", format: "jpg"),
                      fit: BoxFit.fill,
                    ),
                    Image.asset(
                      ImageUtils.getImgPath("img4", format: "jpg"),
                      fit: BoxFit.fill,
                    ),
                  ],
                ),

好了就這樣吧坦喘,如有發(fā)現(xiàn)bug,自己慢慢修改下就行了西设,畢竟沒有項目使用過瓣铣,懶得寫結(jié)尾了,我去追劇了贷揽。

完整代碼見github搜索:flutter_xy

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末棠笑,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子禽绪,更是在濱河造成了極大的恐慌蓖救,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,324評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件印屁,死亡現(xiàn)場離奇詭異循捺,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)雄人,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,356評論 3 392
  • 文/潘曉璐 我一進(jìn)店門巨柒,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人柠衍,你說我怎么就攤上這事【牵” “怎么了珍坊?”我有些...
    開封第一講書人閱讀 162,328評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長正罢。 經(jīng)常有香客問我阵漏,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,147評論 1 292
  • 正文 為了忘掉前任履怯,我火速辦了婚禮回还,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘叹洲。我一直安慰自己柠硕,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,160評論 6 388
  • 文/花漫 我一把揭開白布运提。 她就那樣靜靜地躺著蝗柔,像睡著了一般。 火紅的嫁衣襯著肌膚如雪民泵。 梳的紋絲不亂的頭發(fā)上癣丧,一...
    開封第一講書人閱讀 51,115評論 1 296
  • 那天,我揣著相機(jī)與錄音栈妆,去河邊找鬼胁编。 笑死,一個胖子當(dāng)著我的面吹牛鳞尔,可吹牛的內(nèi)容都是我干的嬉橙。 我是一名探鬼主播,決...
    沈念sama閱讀 40,025評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼铅檩,長吁一口氣:“原來是場噩夢啊……” “哼憎夷!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起昧旨,我...
    開封第一講書人閱讀 38,867評論 0 274
  • 序言:老撾萬榮一對情侶失蹤拾给,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后兔沃,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體蒋得,經(jīng)...
    沈念sama閱讀 45,307評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,528評論 2 332
  • 正文 我和宋清朗相戀三年乒疏,在試婚紗的時候發(fā)現(xiàn)自己被綠了额衙。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,688評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡怕吴,死狀恐怖窍侧,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情转绷,我是刑警寧澤伟件,帶...
    沈念sama閱讀 35,409評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站议经,受9級特大地震影響斧账,放射性物質(zhì)發(fā)生泄漏谴返。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,001評論 3 325
  • 文/蒙蒙 一咧织、第九天 我趴在偏房一處隱蔽的房頂上張望嗓袱。 院中可真熱鬧,春花似錦习绢、人聲如沸渠抹。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,657評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽逼肯。三九已至,卻和暖如春桃煎,著一層夾襖步出監(jiān)牢的瞬間篮幢,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,811評論 1 268
  • 我被黑心中介騙來泰國打工为迈, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留三椿,地道東北人。 一個月前我還...
    沈念sama閱讀 47,685評論 2 368
  • 正文 我出身青樓葫辐,卻偏偏與公主長得像搜锰,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子耿战,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,573評論 2 353

推薦閱讀更多精彩內(nèi)容