より良いユーザー体験を求めて "Skeleton UI" について深掘りする
個人開発でTokeruという個人タスク管理ツールを作っている者です。
このアプリは「独り言を呟きながらタスクを管理する」をテーマにしています。
今回はそのTokeruでSkeleton UIを実装しました。
この記事ではSkeletonで実装する理由から、リソースの少ない個人開発でも簡単に取り入れていく方法について考えていきます。
Skeleton UIとは
Skeleton UIとは読み込みUIの一種で、コンテンツの枠組みだけを先に表示させてあげる方法です。
今回紹介する例は、SlackなどにもあるようなURLからOGPを表示する機能です。
Tokeruで実装した機能のSkeleton UIと読み込み後のコンテンツのUIです。
Skeletonを使う理由は、レイアウトシフトをなくすため
読み込み中のUIとしては他にはCircularProgressIndicator
を使うことも多いのではないでしょうか。
SkeletonUIを実装するとなるとCircularProgressIndicator
と比べるとまあまあ面倒なのですが、そのメリットについて考えてみます。
SkeletonUIを使うメリットは2つあると考えています。
- 読み込みの体感時間が短くなる
- レイアウトシフトがなくなる
個人的には、2つ目の「レイアウトシフトがなくなる」のメリットがあると思って導入しています。
理由1. 読み込みの体感時間が短くなる
SkeletonUIを使うと、ユーザーはコンテンツの読み込み時間を多少、短く感じるそうです。
個人的な感覚でも短く感じるというか、安心して待てる感覚はあります。
例えばCircularProgressIndicator
で待っているよりもSkeletonUIで待っている方が、「どんなコンテンツを準備してくれているか」を予想しやすく、予想しやすいものの方がユーザーはストレスに感じにくいはずです。
例えばこのUIでは、タイトル、説明、画像が表示されることが予想でき、表示される位置まで知ることができます。
タイトル、説明、画像が表示されることが予想できる
理由2. レイアウトシフトがなくなる
SkeletonUIをちゃんと実装できれば、レイアウトシフトを避けることができます。ちゃんと実装できていない例は後述します。
レイアウトシフトとは、UIの要素が意図せずいきなり動いてしまう現象のことです。レイアウトシフトが起こると、一般的に言われている問題としては誤ってクリックしてしまったり、カクツクため視覚的にも優しくありません。
こちらのGIFは今回開発した機能であえてSkeletonUIを使わず、何も表示しない(Flutter的にはSizedBox.shirnk()
を使う)場合で実装した例です。
コンテンツが読み込まれたタイミングでコンテンツの周りの要素の位置も動いてしまいます。
SkeletonUIを使わない例
SkeletonUIを使った例も載せておきます
SkeletonUIを使った例
逆にSkeletonUIとコンテンツのUIが大きくかけ離れている場合、結局レイアウトシフトが起こってしまいます。
サービスによってはどんなコンテンツが読み込まれるかは、読み込まれてからしかわからない場合もあると思います。その場合、あえてSkeletonUIを使わない選択もあると思いますが、LINEの記事が参考になりそうなので一応置いておきます。(詳細は割愛)
ここからはSkeletonUIを実装する際に気をつけていることやTips的なことを書いていきます。
コードはFlutterで紹介していますが、他のフレームワークでも応用できるはずです。
Flutterで実装する際に試していること
AnimatedSwitcherでSkeletonとコンテンツを切り替える
SkeletonUIからコンテンツに切り替わるときにパッと切り替わってしまうと、目の負担が大きいと個人的には思います。
よりベターにするにはフェードアウト・フェードインしてコンテンツを切り替えるのがよりベターではないでしょうか。
FlutterではAnimatedSwitcher
を使って手軽に要素をフェードアウト・フェードインして切り替えることができます。
以下の動画ではわかりやすいようにフェードアウト・フェードインをあえてゆっくり行なっていますが、実際は150msほどが自然に感じれそうです。
ゆっくりフェードアウト・フェードインする例
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: asyncValue.when(
data: (ogp) {
return SampleWidget();
},
loading: () {
return const SampleWidget.loading();
},
error: (error, _) {
return const SizedBox.shrink();
},
),
);
汎用的なSkeleton Widgetを実装する
SkeletonUIはそこまでリッチな要素を用意するわけではなく、大体はテキストの代わりのSkeletonか画像の代わりのSkeletonかなと思います。
そのため汎用的なSkeletonのコンポーネントを用意しておくと後々導入が楽になりそうです。
汎用的なコンポーネントを用意するにあたって、先ほど「レイアウトシフトがなくなる」で述べた通り、コンテンツと異なる大きさになってしまっては良いとは言えません。そのため、Tokeruでは、特にテキストに関してはフォント自体のサイズ(fontSize
)とテキストの高さ(height
)を考慮したコンポーネントを作っています。
インターフェイスとしてTextStyle
を渡せるようにすると、実際のコンテンツで使うスタイルと統一できて楽です。
SkeletonText(width: 100, style: TextStyle(fontSize: 11, height: 1.4)),
/// [Text]の箇所で使用するスケルトンUI。
class SkeletonText extends StatelessWidget {
/// 幅。
final double width;
/// [Text]で使用しているスタイル。
final TextStyle style;
/// 行数。
final int lineLength;
/// コンストラクタ。
const SkeletonText({
super.key,
required this.width,
required this.style,
this.lineLength = 1,
});
Widget build(BuildContext context) {
// フォントサイズと行の高さからパディングを計算
final padding =
(style.fontSize! * (style.height ?? 1.4) - style.fontSize!) * 0.5;
return Column(
children: [
for (int i = 0; i < lineLength; i++)
Container(
width: width,
padding: EdgeInsets.symmetric(vertical: padding),
child: Container(
height: style.fontSize!,
decoration: BoxDecoration(
color: context.appColors.backgroundSkeleton,
borderRadius: BorderRadius.circular(4),
),
),
),
],
);
}
}
さいごに
読み込み中の画面のデザインは、個人開発はもちろん、業務上の実装でも意外と後回しになったりすることが多いのではないかなと思います。
しかし、ほとんどのプロダクトで必ず考える必要があります。
一瞬しか表示されないUIですが、こだわることでより印象の良いアプリだと認知されるはずです。
TokeruもまだまだSkeletonしかり、こだわりきれてない箇所があります。
よりこだわってその知見をアウトプットしていきたいので良かったらフォローお願いします。
また、TokeruもApp storeで配布しているので、ぜひ触ってみてください。
Skeleton UIをより深ぼるための参考になる記事
Discussion