Flutter 仿X(推特)的個(gè)人主頁(yè)效果,以及微博、小紅書的“聯(lián)動(dòng)滑動(dòng)+吸頂”交互效果

先看效果圖

X推特.gif

源碼直接貼出來,這是 谷歌gemini提供的方案

X(推特)的個(gè)人主頁(yè)效果,以及微博、小紅書的類似頁(yè)面,是移動(dòng)端開發(fā)里非常經(jīng)典的“聯(lián)動(dòng)滑動(dòng)+吸頂”交互。這種效果在原生開發(fā)中叫 CoordinatorLayout (Android),在 Flutter 中,它是圍繞著 NestedScrollView 體系和 Sliver 家族構(gòu)建的。

大廠在實(shí)現(xiàn)這個(gè)效果時(shí),幾乎不會(huì)直接使用 Flutter 原生的 NestedScrollView,因?yàn)樵M件在處理多個(gè) Tab 切換和內(nèi)外滾動(dòng)協(xié)調(diào)時(shí)存在歷史遺留 Bug(比如著名的 Issue #36419,會(huì)導(dǎo)致頭部高度跳動(dòng)或滾動(dòng)位置丟失)。

以下是大廠實(shí)現(xiàn) X (推特) 吸頂效果的標(biāo)準(zhǔn)解法和源碼級(jí)邏輯架構(gòu)。

一、 大廠的底層技術(shù)選型

在國(guó)內(nèi)的 Flutter 圈子(阿里、字節(jié)、騰訊),針對(duì)這種 X 效果的個(gè)人主頁(yè),最通用的工業(yè)級(jí)開源解法是 Flutter Candy 團(tuán)隊(duì)開源的 extended_nested_scroll_view。

大廠解法的核心要點(diǎn):

內(nèi)外滑動(dòng)解耦與同步: 重寫了滾動(dòng)協(xié)調(diào)器(Coordinator),使得內(nèi)層(TabBarView 里的列表)和外層(頭部的展開/收起)的物理滑動(dòng)能完美銜接。

TabBar 吸頂: 使用 SliverPersistentHeader 配合 pinned: true 屬性來實(shí)現(xiàn)完美的物理吸頂。

多列表狀態(tài)隔離: 在內(nèi)層使用具有獨(dú)立 PageStorageKey 的 CustomScrollView。

二、 源碼級(jí)架構(gòu)實(shí)現(xiàn) (骨架代碼)

X 的頁(yè)面結(jié)構(gòu)自上而下分為:封面圖 (SliverAppBar) -> 個(gè)人信息 (SliverToBoxAdapter) -> 吸頂菜單 (SliverPersistentHeader) -> 內(nèi)容列表 (TabBarView)。

以下是高度還原該效果的核心結(jié)構(gòu)代碼:
使用的庫(kù)
extended_nested_scroll_view: ^6.2.1

import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter/material.dart';

class TwitterProfilePage extends StatefulWidget {
  @override
  _TwitterProfilePageState createState() => _TwitterProfilePageState();
}

class _TwitterProfilePageState extends State<TwitterProfilePage> with SingleTickerProviderStateMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 2, vsync: this);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // 1. 使用增強(qiáng)版的 NestedScrollView 解決原生各種卡頓和位置丟失問題
      body: ExtendedNestedScrollView(
        // 只將吸頂?shù)?TabBar 視為 body 的一部分(大廠優(yōu)化的關(guān)鍵參數(shù))
        onlyOneScrollInBody: true, 
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            // 2. 頂部封面圖與導(dǎo)航欄
            SliverAppBar(
              expandedHeight: 200.0,
              pinned: true, // 滑動(dòng)到頂部時(shí)保留 AppBar 的高度
              stretch: true,
              flexibleSpace: FlexibleSpaceBar(
                stretchModes: const [
                StretchMode.zoomBackground,
                ],
                background: Image.network('封面圖URL', fit: BoxFit.cover),
              ),
            ),
            
            // 3. 個(gè)人簡(jiǎn)介區(qū)域 (隨著滑動(dòng)會(huì)完全隱藏)
            SliverToBoxAdapter(
              child: Padding(
                padding: EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    CircleAvatar(radius: 40, backgroundImage: NetworkImage('頭像URL')),
                    Text("Elon Musk", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
                    Text("@elonmusk"),
                    Text("這是我的簡(jiǎn)介..."),
                  ],
                ),
              ),
            ),

            // 4. 核心:吸頂?shù)?TabBar
            SliverPersistentHeader(
              pinned: true, // 核心屬性:滑到頂部時(shí)吸附,不跟著滾走
              delegate: _StickyTabBarDelegate(
                TabBar(
                  controller: _tabController,
                  labelColor: Colors.black,
                  tabs: [Tab(text: "Posts"), Tab(text: "Replies")],
                ),
              ),
            ),
          ];
        },
        
        // 5. 底部?jī)?nèi)容區(qū)域
        body: TabBarView(
          controller: _tabController,
          children: [
            // Tab 1 列表
            _buildTabContent('posts_tab'),
            // Tab 2 列表
            _buildTabContent('replies_tab'),
          ],
        ),
      ),
    );
  }

  // 構(gòu)建內(nèi)部滾動(dòng)的 CustomScrollView
  Widget _buildTabContent(String tabKey) {
    return CustomScrollView(
      key: PageStorageKey<String>(tabKey), // 你之前用對(duì)的技巧,用來保留滾動(dòng)位置
      slivers: [
        SliverList(
          delegate: SliverChildBuilderDelegate(
            (context, index) => ListTile(title: Text("推文 $index")),
            childCount: 50,
          ),
        ),
      ],
    );
  }
}

// ==========================================
// 核心輔助類:必須實(shí)現(xiàn)一個(gè) Delegate 才能讓 TabBar 在 Sliver 中吸頂
// ==========================================
class _StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
  final TabBar tabBar;

  _StickyTabBarDelegate(this.tabBar);

  @override
  double get minExtent => tabBar.preferredSize.height;

  @override
  double get maxExtent => tabBar.preferredSize.height;

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Container(
      color: Colors.white, // 吸頂時(shí)的背景色,防止透明透出底部列表文字
      child: tabBar,
    );
  }

  @override
  bool shouldRebuild(_StickyTabBarDelegate oldDelegate) {
    return tabBar != oldDelegate.tabBar;
  }
}

有朋友可能會(huì)關(guān)注到,為何背景沒有跟隨下拉放大呢,明明在SliverAppBar設(shè)置了stretch: true,

后來我發(fā)現(xiàn)這個(gè)插件可以做到下拉放大,NestedScrollViewPlus,后面我問gemini,extended_nested_scroll_view和NestedScrollViewPlus的區(qū)別
下面也是來gemini(pro版本)的解釋:
這是一個(gè)非常底層且考驗(yàn)對(duì) Flutter 滾動(dòng)機(jī)制(Scroll Mechanics)理解的問題。

簡(jiǎn)單直接的回答是:它們內(nèi)部的 _NestedScrollCoordinator(滾動(dòng)協(xié)調(diào)器)處理「Overscroll(越界滑動(dòng)/彈性滑動(dòng))」的邏輯不同。

NestedScrollViewPlus 專門重寫了底層的偏移量映射機(jī)制,把內(nèi)層列表拉到頂部的“多余拉力”傳遞給了外層;而 extended_nested_scroll_view 的側(cè)重點(diǎn)不同,它阻斷了這個(gè)傳遞,導(dǎo)致外層的 SliverAppBar 接收不到拉伸信號(hào)。

下面是詳細(xì)的深度對(duì)比:

1. Flutter 原生 NestedScrollView 的歷史遺留缺陷

要理解這兩個(gè)第三方庫(kù),必須先知道原生的痛點(diǎn)。Flutter 官方的 NestedScrollView 一直存在一個(gè)著名的 Issue(#54059):不支持外層越界滾動(dòng)(Outer Scroller Overscroll)。

當(dāng)你在內(nèi)層列表(比如 TabBarView 里的 ListView)往下拉,拉到頂部(Offset = 0)繼續(xù)用力拉時(shí):

原生的機(jī)制里,內(nèi)層會(huì)自己產(chǎn)生一個(gè)“回彈(Bouncing)”或者“水波紋”效果。

這個(gè)“越界拉力”被內(nèi)層吞掉了,沒有辦法傳遞給外層的 SliverAppBar。

因此,即使你在 SliverAppBar 設(shè)置了 stretch: true,F(xiàn)lexibleSpaceBar 也無動(dòng)于衷,因?yàn)樗静恢滥阍谕伦А?/p>

2. extended_nested_scroll_view 的設(shè)計(jì)取向

extended_nested_scroll_view(由 Flutter Candy 團(tuán)隊(duì)開發(fā))是一個(gè)較早且非常著名的庫(kù)。

它的核心使命: 解決原生 NestedScrollView 多個(gè) Tab 切換時(shí)滾動(dòng)位置互相干擾、互相重置的致命 Bug(Issue #36419),以及解決吸頂元素(Pinned Header)的各種異常。

對(duì)待 Overscroll 的態(tài)度: 它在重寫 Coordinator 時(shí),重心放在了“隔離”和“解耦”內(nèi)外列表的狀態(tài)上。當(dāng)內(nèi)層列表拉到頂部繼續(xù)下拉時(shí),它默認(rèn)將這個(gè) Overscroll 交給內(nèi)層自己處理(比如觸發(fā) iOS 的果凍回彈),或者交給最外層包裹的下拉刷新(RefreshIndicator)。它沒有刻意打通將下拉阻力回傳給外層 SliverAppBar 的通道。

結(jié)果: 沒有下拉偏移量傳入,F(xiàn)lexibleSpaceBar 就不會(huì)觸發(fā)放大效果。

3. NestedScrollViewPlus 的“降維打擊”

NestedScrollViewPlus 是較新的一個(gè)庫(kù)(其底層思路大量借鑒了 GitHub 上的 custom_nested_scroll_view 項(xiàng)目)。它在建立之初,就明確把“解決外層 SliverAppBar 無法 Stretch(拉伸放大)”作為一個(gè)核心賣點(diǎn)。

它是如何做到的?
它極度暴力且精準(zhǔn)地重寫了 _NestedScrollCoordinator 中的幾個(gè)核心方法,特別是 applyUserOffset 和指標(biāo)映射方法(unnestOffset, nestOffset)。

物理邏輯鏈路:
當(dāng)你下拉內(nèi)層列表到達(dá)頂部(Offset <= 0)時(shí),NestedScrollViewPlus 的協(xié)調(diào)器會(huì)攔截這個(gè)“多余的下拉位移(Delta)”,強(qiáng)行將其作用在 _outerPosition(外層滾動(dòng)位置)上。

結(jié)果: 外層的 SliverAppBar 接收到了負(fù)數(shù)的 Offset!它瞬間“明白”了用戶在下拉,于是配合 stretch: true,F(xiàn)lexibleSpaceBar 就完美地觸發(fā)了背景圖放大(Zoom/Scale)的效果。

所以 ,如果想要背景圖放大就使用NestedScrollViewPlus 即可

另外,提一嘴,TabBarView的子元素不能使用StatefulWidget來嵌套CustomScrollView,而且你如果使用StatefulWidget來嵌套,狀態(tài)也不好保存,即使你使用AutomaticKeepAliveClientMixin來保存狀態(tài)后,你會(huì)發(fā)現(xiàn)上滑滑動(dòng) 聯(lián)動(dòng)不流暢了。

所以TabBarView的子元素只能是多個(gè)CustomScrollView,并且設(shè)置key: PageStorageKey<String>(tabKey),來滑動(dòng)位置,感興趣可以搜索PageStorageKey的恢復(fù)邏輯

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容