💀

SkeletonをFlutterで作れるのか?

2024/09/22に公開

What Skeleton?

Skeletonとはローディング中にアバターの画像やテキストがグレーで表示されているUIですね。
海外のサイトにどんなものか解説がございました。英語翻訳して読んでみてください。
https://uxdesign.cc/what-you-should-know-about-skeleton-screens-a820c45a571a

使うメリットは?

ローディングスピナーなどのクルクル回るやつと比較すると何が良いのか?
海外のサイトの情報を調べてまとめてみました。

スケルトンローディングを使用するメリットは多岐にわたります。主な利点を以下に説明します:

  1. ユーザーエクスペリエンスの向上:

スケルトンローディングは、コンテンツの構造を事前に表示することで、ユーザーに何が読み込まれているかの視覚的な手がかりを提供します。これにより、ユーザーは待ち時間をより短く感じ、アプリケーションがより応答性が高いと認識します。

  1. 知覚されるパフォーマンスの向上:
    実際の読み込み時間が変わらなくても、ユーザーはアプリケーションがより速く動作していると感じます。これは、ユーザーに即座にフィードバックを提供することで、待ち時間の認識を減少させるためです。

  2. コンテンツのレイアウトシフトの軽減:
    スケルトンは実際のコンテンツと同じ大きさと形状を持つため、コンテンツが読み込まれた際のレイアウトシフトが最小限に抑えられます。これにより、ページの安定性が向上し、ユーザーのフラストレーションを軽減します。

  3. プログレッシブな読み込みの視覚化:
    複数の要素がある場合、スケルトンは各要素が順次読み込まれていく様子を視覚的に表現できます。これにより、ユーザーは進行状況をより明確に理解できます。

  4. ブランディングとの一貫性:
    スケルトンはアプリケーションのデザインに合わせてカスタマイズできるため、ブランディングとの一貫性を保つことができます。これは、単純なローディングスピナーよりも洗練された印象を与えます。

  5. ネットワーク接続の遅さへの対応:
    特に遅いネットワーク接続の場合、スケルトンローディングはユーザーに何かが起こっているという視覚的なフィードバックを提供し続けます。これにより、ユーザーはアプリケーションがフリーズしていないことを理解できます。

  6. アクセシビリティの向上:
    スクリーンリーダーなどの支援技術を使用するユーザーにとって、スケルトンローディングは「コンテンツが読み込み中」であることを明確に伝えることができます。

  7. コンテンツの予測可能性:
    ユーザーはスケルトンを見ることで、どのような種類のコンテンツが読み込まれるかを予測できます。これにより、ユーザーの期待値を適切に設定し、コンテンツへの準備を整えることができます。

  8. エラー状態の表示の改善:
    コンテンツの読み込みに失敗した場合、スケルトンの代わりにエラーメッセージを表示することで、ユーザーにより明確なフィードバックを提供できます。

スケルトンローディングを実装することで、これらの利点を活用し、全体的なユーザーエクスペリエンスを大幅に向上させることができます。特に、データの読み込みに時間がかかるアプリケーションや、複雑なレイアウトを持つウェブサイトで効果的です。

自作してみた

YOUTRUSTというアプリでも使われてるあの機能を使ってみたいと思ったが、同じようなUIを再現できるパッケージが更新されていない💦
https://pub.dev/packages/shimmer

似たパッケージもあるが見た目た好みではなかった。実験用のアプリで作っているものなので、ConsumerStatefulWidgetで書いておりますが、StatefulWidgetでも動作します。
https://pub.dev/packages/skeletonizer/example

こんな感じです✨
https://x.com/JBOY83062526/status/1837725418255692136

なので自作する。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:signals/signals_flutter.dart';

class CustomShimmer extends StatefulWidget {
  final Widget child;
  final Color baseColor;
  final Color highlightColor;
  final Duration duration;

  const CustomShimmer({
    super.key,
    required this.child,
    this.baseColor = const Color(0xFFE0E0E0),
    this.highlightColor = const Color(0xFFF5F5F5),
    this.duration = const Duration(milliseconds: 1500),
  });

  
  _CustomShimmerState createState() => _CustomShimmerState();
}

class _CustomShimmerState extends State<CustomShimmer> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this, duration: widget.duration)
      ..repeat(reverse: true);
    _animation = Tween<double>(begin: -2, end: 2).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOutSine),
    );
  }

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

  
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return ShaderMask(
          blendMode: BlendMode.srcATop,
          shaderCallback: (bounds) {
            return LinearGradient(
              begin: Alignment(_animation.value, 0),
              end: Alignment(_animation.value + 1, 0),
              colors: [widget.baseColor, widget.highlightColor, widget.baseColor],
              stops: const [0.0, 0.5, 1.0],
            ).createShader(bounds);
          },
          child: widget.child,
        );
      },
    );
  }
}

class ShimmerLoading extends StatelessWidget {
  final bool isLoading;
  final Widget child;
  final Widget loadingWidget;

  const ShimmerLoading({
    super.key,
    required this.isLoading,
    required this.child,
    required this.loadingWidget,
  });

  
  Widget build(BuildContext context) {
    if (!isLoading) {
      return child;
    }
    return CustomShimmer(
      child: loadingWidget,
    );
  }
}

class HomeScreen extends ConsumerStatefulWidget {
  const HomeScreen({super.key});

  
  ConsumerState<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends ConsumerState<HomeScreen> with SignalsMixin {
  late final Signal<bool> enabled = this.createSignal(true);


  Widget build(BuildContext context) {
    return  Scaffold(
      appBar: AppBar(title: const Text('Custom Shimmer Example')),
      body: ShimmerLoading(
        isLoading: true,
        loadingWidget: _buildSkeletonContent(), // ここで実際のローディング状態を制御
        child: _buildContent(),
      ),
    );
  }
  Widget _buildContent() {
    return ListView.builder(
      itemCount: 10,
      itemBuilder: (context, index) {
        return ListTile(
          leading: CircleAvatar(child: Text('${index + 1}')),
          title: Text('Item ${index + 1}'),
          subtitle: Text('Description for item ${index + 1}'),
        );
      },
    );
  }

  Widget _buildSkeletonContent() {
    return ListView.builder(
      itemCount: 10,
      itemBuilder: (context, index) {
        return ListTile(
          leading: const CircleAvatar(backgroundColor: Colors.white),
          title: Container(
            width: double.infinity,
            height: 16,
            color: Colors.white,
          ),
          subtitle: Container(
            width: double.infinity,
            height: 12,
            color: Colors.white,
          ),
        );
      },
    );
  }
}

最後に

くるくる回るインジケーターがいやな人がいたらSkeletonを自作するかパッケージを使ってみるとユーザー体験が変わるかもしれません。デザインの力ってすごいですね。

Discussion