Flutter 實現骨架屏

HuStoking發表於2024-09-30

什麼是骨架屏

在客戶端開發中,我們總是需要等待拿到服務端的響應後,再將內容呈現到頁面上,那麼在使用者發起請求到客戶端成功拿到響應的這段時間內,應該在螢幕上呈現點什麼好呢?

答案是:骨架屏

那麼什麼是骨架屏呢,來問下 GPT:

骨架屏(Skeleton Screen)是一種現代的使用者介面設計技術,用於提升應用或網站在載入內容時的使用者體驗。在內容的完全載入和呈現之前,骨架屏提供了一種模糊的預覽,形似最終內容的空白版,通常用灰色的塊、線條或元素佔位符表示。這種設計方法可以有效減少使用者的感知等待時間,增強使用者的互動感。

功能和用途

  1. 提高感知效能:骨架屏透過立即顯示頁面的基本結構(而非旋轉的載入圖示或完全空白的螢幕),給使用者一種內容即將呈現的感覺,這可以使等待時間感覺上更短。

  2. 改善使用者體驗:使用骨架屏可以減少使用者在載入過程中的焦慮,使用者看到介面元素已經在逐步載入,會有更多的耐心等待最終內容的呈現。

  3. 提供內容載入的視覺提示:骨架屏體現了頁面內容載入的進度,可以讓使用者知道哪些內容即將出現,這樣使用者就不會感到突然或困惑。

實現方式

骨架屏的實現通常包括以下幾個步驟:

  1. 設計:設計與最終內容佈局相似的基本框架,使用灰色或淺色塊代表將要載入的各種元素,如文字行、圖片、按鈕等。

  2. 前端實現:在前端程式碼中,可以使用HTML和CSS來建立這些佔位符。對於複雜的動態載入內容,可以使用JavaScript或前端框架如React、Vue等來動態控制骨架屏的顯示和隱藏。

  3. 資料載入後的處理:一旦相應的資料載入完成,骨架屏應被實際內容替換。這通常涉及到監聽資料載入的完成事件,然後更新UI。

示例

在一個簡單的網頁應用中,如果你正在載入一個包含標題、幾段文字和圖片的文章,骨架屏可能包括:

  • 一個灰色的矩形塊預留給圖片。
  • 幾個灰色的條形預留給文字標題和段落。

隨著實際內容的逐漸載入到瀏覽器中,這些灰色佔位符將被實際的圖片和文字內容替換。

結論

骨架屏是一種非常有效的使用者介面技術,尤其適用於網路速度較慢或資料處理較慢的應用場景,能顯著提升使用者的等待體驗和整體滿意度。透過合理設計和實現,開發者可以利用骨架屏減少使用者流失,提升應用的專業感和友好感。

我們要實現的效果

上面是一個 內容展示的卡片,下面的是 載入中狀態的該卡片的骨架圖

如何實現呢?

1. 先定義卡片部分 ui 程式碼

一個 Column 中有三行元素,分別是 第一行: 圖片第二行: 卡片標題第三行: 頭像 暱稱 瀏覽量

class StarCard extends StatelessWidget {
  const StarCard({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 180,
      clipBehavior: Clip.hardEdge,
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(4),
      ),
      child: Column(
        children: [
          SizedBox(
            width: 180,
            child: AspectRatio(
              aspectRatio: 9 / 11,
              child: Image.network('https://pic1.zhimg.com/80/v2-fc35089cfe6c50f97324c98f963930c9_720w.jpg', fit: BoxFit.cover),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(8),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  '魔法少女李知恩!!!',
                  style: TextStyle(fontSize: 15, color: Colors.black, fontWeight: FontWeight.w500),
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
                SizedBox(height: 8),
                Row(
                  children: [
                    ClipRRect(
                      borderRadius: BorderRadius.circular(10),
                      child: Image.network(
                        'https://pic1.zhimg.com/80/v2-1956eeb2c894f1785362411aa306f882_1440w.webp?source=1def8aca',
                        height: 20,
                        width: 20,
                        fit: BoxFit.cover,
                      ),
                    ),
                    SizedBox(width: 4),
                    Text('是 IU 吖', style: TextStyle(fontSize: 12, color: const Color(0xff86909C))),
                    const Spacer(),
                    Text('21 瀏覽', style: TextStyle(fontSize: 12, color: const Color(0xff86909C))),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

2. 引入 shimmer

pubspec.yaml 中加入

dependencies:
  shimmer: ^3.0.0
  • 該 package 的文件在這: https://pub.dev/packages/shimmer

其作用是為我們的元素加上 閃動 流光 效果,類似於一道光照射到一把光滑的寶劍上,隨著寶劍角度發生變化 光的反射發生位移的現象

我們使用它的 fromColors 構造器,先隨便放進去一個 Container 試試效果:

Shimmer.fromColors(
  baseColor: Colors.orange,
  highlightColor: Colors.blue,
  child: Container(
	color: Colors.white,
	height: 180,
	width: 180,
  ),
)

效果還不錯,就是配色有點醜

調整下顏色,在用來包裹下我們剛剛定義的 StarCard :

Shimmer.fromColors(
  baseColor: Colors.grey[300]!, // 骨架基色
  highlightColor: Colors.grey[100]!, // 骨架高亮色
  child: StarCard(),
),

誒?怎麼跟我們要實現的效果有點出入?

這是因為 StarCard 的根元件是一個帶有顏色的 Container

return Container(
  width: 180,
  clipBehavior: Clip.hardEdge,
  decoration: BoxDecoration(
	color: Colors.white,
	borderRadius: BorderRadius.circular(4),
  ),
  ...
);

而這樣 Shimmer 效果便會被加到整個跟元件上,child 也就看不到 Shimmer 了。那我們將根元件的 color 屬性移除試試呢:

嗯...... 底層的元素確實展示出來了,不過我們所期望的並不是展示出文字和資料啊,況且這個時候我們還尚未拿到伺服器返回給我的的資料

3. Magic symbol

這個時候我們就需要用到一個 魔法符號 ,不過在引入之前,先分離下元件:

StarCard 需要一個建構函式,裡面接收一個從服務端 反序列化 來的 model ;再新定義一個 StarCardSkeleton 元件,他有一個無參構造器,用作 StarCard 的骨架圖;也就是說在獲取到資料之前,我們使用一個 StarCardSkeleton 來佔 StarCard 的位,獲取到資料之後使用 StarCard 來展示真實的資料,程式碼如下:

class StarCard extends StatelessWidget {
  const StarCard({super.key, required this.starModel});

  final StarModel starModel;

  @override
  Widget build(BuildContext context) {...}
}

class StarCardSkeleton extends StatelessWidget {
  const StarCardSkeleton({super.key});

  @override
  Widget build(BuildContext context) {...}
}

現在該回歸正題了,我們要引入的 魔法符號 就是:

這是一個 全寬 純色 佔位符,數個 連起來,配合加粗 fontWeight 可以實現我們想要的 一道長條色塊 的效果,且要比定義 SizedBox 少改動更多程式碼,用它來代替Shimmer 文字再合適不過了

我們先將 StarCard build 中的程式碼全部複製到 StarCardSkeleton 裡面,並改動 卡片標題使用者暱稱瀏覽量 Text 中的文字為自定義數量的

class StarCardSkeleton extends StatelessWidget {
  const StarCardSkeleton({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 180,
      clipBehavior: Clip.hardEdge,
      decoration: BoxDecoration(
        // color: Colors.white,
        borderRadius: BorderRadius.circular(4),
      ),
      child: Column(
        children: [
          SizedBox(
            width: 180,
            child: AspectRatio(
              aspectRatio: 9 / 11,
              child: Container(color: Colors.white),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(8),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  '▆▆▆▆▆▆',
                  style: TextStyle(fontSize: 15, fontWeight: FontWeight.w900),
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
                SizedBox(height: 8),
                Row(
                  children: [
                    Container(
                      width: 20,
                      height: 20,
                      decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.white),
                    ),
                    SizedBox(width: 4),
                    // NickNameText(articleData.nickName, views: articleData.playTimes),
                    Text('▆▆▆', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w900)),
                    const Spacer(),
                    Text('▆▆', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w900)),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

如果想進一步最佳化渲染效能,可以把 Image 換成帶背景色的 Container

再看下效果呢

完美!簡直一模一樣!

抽離出 Simmer 元件

為了方便元件複用,可以將 Shimmer 封裝出來

/// 骨架屏閃爍
class BaseShimmer extends StatelessWidget {
  const BaseShimmer({super.key, required this.child});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Shimmer.fromColors(
      baseColor: Colors.grey[300]!, // 骨架基色
      highlightColor: Colors.grey[100]!, // 骨架高亮色
      child: child,
    );
  }
}

再次用到可以直接:BaseShimmer(child: StarCardSkeleton())

擴充

這章的標題是 骨架屏 ,為什麼從頭到尾一直再講怎麼生成一個骨架圖呢?

先別急罵標題黨!

所謂的 骨架屏 不就是一張一張的骨架圖拼出一個螢幕,不就是一個骨架屏 嘛

BaseShimmer(
	child: MasonryGridView.count(
		padding: EdgeInsets.only(bottom: 10, left: 12, right: 12, top: 8),
		physics: const NeverScrollableScrollPhysics(),
		itemCount: 9,
		mainAxisSpacing: 5,
		crossAxisSpacing: 5,
		crossAxisCount: 2,
		itemBuilder: (BuildContext context, int index) {
		  return StarCardSkeleton();
		}),
	)

這裡用了 flutter_staggered_grid_view 包,這個包還可以做出卡片高度不一的瀑布流佈局效果

注:使用 BaseShimmer 包裹整個 GridViewListView 比包裹單個的 Card 效果要更好喲~

風險

經過測試發現 在不同的裝置上、或者使用了自定義字型,▆▆▆ 之間會出現微小間距,無論將 fontWeight 設定為多大都無法避免,這時只能將 方案換為帶顏色的 Container 來解決。不過 SkeletonScreen 在頁面停留的時間通常不會太長,這點就要看團隊內部的取捨了

相關文章