May 31, 2022

【Flutter】Blurをかけてもっとスクロールできる雰囲気をだす

ScrollableなViewに対してまだスクロールできる感をだすための手法として、よくスクロール方向にBlurをかけて見せる手法があります。
FlutterでもListViewやWebView, Textに対してBlurをかけてスクロールできる感を出してみます。

基本知識

Blurをつくるためには ShaderMask を使います。
ShaderMaskはChildに対してグラデーションや画像を重ねるなど任意のShaderを設定できます。

今回は縦方向にBlurを追加するだけなので、LinearGradientを使います。
LienarGradientのrequiredなプロパティは colors のみなので最低限色を指定するだけでも良いのですが、1色のみだとただMaskがかかるだけなので何も見えなるので2色指定します。(ここは好みなので適宜変えてください)

ShaderMask(
  shaderCallback: (bounds) {
    return const LinearGradient(
      colors: [
        Colors.white,
        Colors.transparent,
      ],
    ).createShader(bounds);
  },
  child: ChildWidget(),
);

LinearGradientはデフォルトでは左から右方向に重なります。今回は下方向にBlurを重ねたかったのでbeginendを指定します。

ShaderMask(
  shaderCallback: (bounds) {
    return const LinearGradient(
      begin: Alignment.topCenter,
      end: Alignment.bottomCenter,
      colors: [
        Colors.white,
        Colors.transparent,
      ],
    ).createShader(bounds);
  },
  child: ChildWidget(),
);

これで縦方向にMaskがかかりました。ただ、これだと中心からMaskがかかってしまうのでstopsを指定してグラデーションの幅を調整します。
stopsに色ごとの始点を指定します。今回Transparentは見えない部分になるので1にしています。

ShaderMask(
  shaderCallback: (bounds) {
    return const LinearGradient(
      begin: Alignment.topCenter,
      end: Alignment.bottomCenter,
      colors: [
        Colors.white,
        Colors.transparent,
      ],
      stops: [
        0.8,
        1,
      ],
    ).createShader(bounds);
  },
  child: ChildWidget(),
);

それっぽいスクロールできる感が醸し出されました。

スクロールに合わせて表示を消す

このままだとコンテンツの一番下までスクロールしてもまだスクロールできます感が残ったままになってしまいます。そのため、スクロールの位置を監視してShaderMaskの表示を切り替える必要があります。
主なViewについてやり方を解説します。

ShaderMaskを単純に非表示にするにはColors.whiteのstopsを1から始めてあげればMaskがかからなくなるのでBlurが消えます。

ShaderMask(
  shaderCallback: (bounds) {
    return const LinearGradient(
      begin: Alignment.topCenter,
      end: Alignment.bottomCenter,
      colors: [
        Colors.white,
        Colors.transparent,
      ],
      stops: [
        1,
        1,
      ],
    ).createShader(bounds);
  },
  child: ChildWidget(),
);

ListView

ScrollControllerを使って先程のShaderMaskを切り替えてあげれば良いです。
最大の高さは ScrollController.position.maxScrollExten、現在の高さはScrollController.position.pixels で取れます。

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Blur scroll'),
      ),
      body: const _Body(),
    );
  }
}

class _Body extends StatefulWidget {
  const _Body({Key? key}) : super(key: key);

  @override
  State<_Body> createState() => _BodyState();
}

final List<int> listItem = List<int>.generate(100, (i) => 0xe000 + i + 1);

class _BodyState extends State<_Body> {
  final ScrollController controller = ScrollController();

  bool showBlur = true;

  @override
  void initState() {
    super.initState();

    controller.addListener(() {
      setState(() {
        showBlur =
            controller.position.pixels < controller.position.maxScrollExtent;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return ShaderMask(
      shaderCallback: (bounds) {
        return LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: const [Colors.white, Colors.transparent],
          stops: [showBlur ? 0.8 : 1, 1],
        ).createShader(bounds);
      },
      child: _ListView(
        listItem: listItem,
        controller: controller,
      ),
      blendMode: BlendMode.dstIn,
    );
  }
}

class _ListView extends StatelessWidget {
  const _ListView({
    Key? key,
    required this.listItem,
    required this.controller,
  }) : super(key: key);

  final List<int> listItem;
  final ScrollController controller;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: controller,
      itemBuilder: (BuildContext context, int index) {
        return Container(
          decoration: const BoxDecoration(
            border: Border(
              bottom: BorderSide(color: Colors.black12),
            ),
          ),
          child: ListTile(
            leading: Icon(
              IconData(listItem[index], fontFamily: 'MaterialIcons'),
            ),
            title: Text('$index'),
          ),
        );
      },
      itemCount: listItem.length,
    );
  }
}

TextView(ScrollView)

Textも簡単ですScrollViewで囲ってあげれば先程のListViewと入れ替えるだけでそのまま使えます。

class _ScrollView extends StatelessWidget {

  const _ScrollView({
    Key? key,
    required this.controller,
  }) : super(key: key);

  final ScrollController controller;

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      controller: controller,
      child: const Text(dummyText),
    );
  }
}

同じようにScrollの位置をハンドリングできればWebViewでも同じことができます。

まとめ

開発者から見たらスクロールできるって普通考えるだろうと思っても、ユーザーは意外とスクロールできることに気が付かなかったりします。
要らないような気もしますがあるとスクロールしてくれる確率は上がると思うので、読ませたいコンテンツがある場合などここぞという時に使うといいでしょう。

© AAkira 2023