🦦

flutterでScrollspyとposition:stickyを実装してみる

2023/11/17に公開

久々に投稿します。
gif早くしたかったけどちょっと面倒でした、すいません。。

Q1.横タブメニューでその要素(指定widget)にページ内遷移しつつ押下したメニューがアクティブなるのwebでなんて言うんだっけ?

Q2.あと、要素(ボタンなど)を特定の位置までは画面下に固定、特定の位置から固定させる方法なんて言うんだっけ?

から始まりました。

▪︎とりあえず成果物。

A1.Scrollspy(Bootstrap)

スクロール位置に基づいてBootstrapナビゲーションまたはリストグループコンポーネントを自動的に更新し、ビューポート内で現在どのリンクがアクティブであるかを示します。
https://getbootstrap.jp/docs/5.3/components/scrollspy/

A2.position:sticky(css)

position は CSS のプロパティで、文書内で要素がどのように配置されるかを設定します。 top, right, bottom, left の各プロパティが、配置された要素の最終的な位置を決めます。
https://developer.mozilla.org/ja/docs/Web/CSS/position

iosでなんかメニューがスライドする時に横にびよ~んて伸びるのどうにかしたい。
【Flutter】スクロールの挙動をカスタマイズする(引っ張り時のエフェクトを無効にする

https://zenn.dev/tsukatsuka1783/articles/flutter_scroll_configuration

▪︎Code

import 'package:flutter/material.dart';

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

  
  State<DetailScreen> createState() => _DetailScreenState();
}

class _DetailScreenState extends State<DetailScreen> {
  final ScrollController _scrollController = ScrollController();
  final ScrollController _horizontalScrollController = ScrollController();

  final List<String> _sections = [
    'セクションA',
    'セクションB',
    'セクションC',
    'セクションD',
    'セクションF'
  ];
  int _currentSectionIndex = 0;
  bool _itemVisibility = true;
// GlobalKeyを個々に宣言
  final GlobalKey _sectionAKey = GlobalKey();
  final GlobalKey _sectionBKey = GlobalKey();
  final GlobalKey _sectionCKey = GlobalKey();
  final GlobalKey _sectionDKey = GlobalKey();
  final GlobalKey _sectionFKey = GlobalKey();
  final GlobalKey _listViewKey = GlobalKey();
  final GlobalKey _buttonKey = GlobalKey();

  
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
  }

  
  void dispose() {
    _scrollController.removeListener(_onScroll);
    _scrollController.dispose();
    super.dispose();
  }

  ///スクロールの処理
  void _onScroll() {
    final appBarHeight = AppBar().preferredSize.height; // AppBarの高さを取得
    final List<GlobalKey> keys = [
      _sectionAKey,
      _sectionBKey,
      _sectionCKey,
      _sectionDKey,
      _sectionFKey
    ];

    double accumulatedHeight = 0.0;

    for (int i = 0; i < keys.length; i++) {
      final RenderBox box =
          keys[i].currentContext?.findRenderObject() as RenderBox;
      final position = box.localToGlobal(Offset.zero);

      // セクションの高さを累積
      accumulatedHeight += box.size.height;

      // スクロール位置がこのセクションの範囲内にあるか確認
      if (_scrollController.offset >= position.dy - appBarHeight &&
          _scrollController.offset <
              position.dy - appBarHeight + accumulatedHeight) {
        if (_currentSectionIndex != i) {
          setState(() {
            _currentSectionIndex = i;
            print("現在のセクション: ${_sections[i]}");
            _centerSectionTab(i);
          });
        }
        break;
      }
    }

    ///スクロール応募ボタン位置判定
    final RenderBox buttonBox =
        _buttonKey.currentContext?.findRenderObject() as RenderBox;
    final buttonPosition = buttonBox.localToGlobal(Offset.zero);
    final buttonYPosition = buttonPosition.dy;
    final screenHeight = MediaQuery.of(context).size.height;
    final widgetHeight = buttonBox.size.height;

    if (buttonYPosition >= screenHeight - widgetHeight) {
      //画面下部に固定されたコンテナ下部固定
      setState(() {
        _itemVisibility = true;
      });
    } else {
      //画面下部に固定されたコンテナ要素内固定
      setState(() {
        _itemVisibility = false;
      });
    }
  }

  ///タブの移動処理
  void _centerSectionTab(int index) {
    final RenderBox? tabBox =
        _listViewKey.currentContext?.findRenderObject() as RenderBox?;
    if (tabBox == null) return;
    //_listViewKey を使用して、リストビューのサイズを取得します。
    // リストビューの幅を screenWidth として取得します。
    final double screenWidth = tabBox.size.width;
    //各セクションの幅を表します。これはスクロールビューを均等に分割するために、
    // スクリーン幅をセクションの数で割った値です。つまり、各セクションの幅がどれくらいであるかを計算します。
    final double tabWidth = screenWidth / _sections.length;
    //
    final double scrollPosition =
        (index * tabWidth) - (screenWidth / 2) + (tabWidth / 2);

    _horizontalScrollController.animateTo(
      scrollPosition,
      duration: Duration(milliseconds: 300),
      curve: Curves.easeInOut,
    );
  }

  void _scrollToSection(GlobalKey targetKey) {
    final List<GlobalKey> keys = [
      _sectionAKey,
      _sectionBKey,
      _sectionCKey,
      _sectionDKey,
      _sectionFKey
    ];

    double accumulatedHeight = 0.0;

    // 目的のセクションの前までの高さを累積
    for (GlobalKey key in keys) {
      if (key == targetKey) {
        break; // 目的のセクションに到達する前にループを終了
      }
      final RenderBox box = key.currentContext?.findRenderObject() as RenderBox;
      accumulatedHeight += box.size.height;
    }
    final positionToScroll = accumulatedHeight; // AppBarの高さを考慮したスクロール位置
    _scrollController.animateTo(
      positionToScroll,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeInOut,
    );
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scroll Spy Example'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(20),
          child: Flexible(
            child: Container(
              color: Colors.grey[200],
              child: ListView.builder(
                key: _listViewKey,
                physics: const ClampingScrollPhysics(), //iosの横ひっぱりエフェクト回避
                controller: _horizontalScrollController,
                scrollDirection: Axis.horizontal,
                itemCount: _sections.length,
                itemBuilder: (context, index) {
                  return GestureDetector(
                    onTap: () {
                      switch (index) {
                        case 0:
                          _scrollToSection(_sectionAKey);
                          break;
                        case 1:
                          _scrollToSection(_sectionBKey);
                          break;
                        case 2:
                          _scrollToSection(_sectionCKey);
                          break;
                        case 3:
                          _scrollToSection(_sectionDKey);
                          break;
                        case 4:
                          _scrollToSection(_sectionFKey);
                          break;
                      }
                    },
                    child: Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Text(
                        _sections[index],
                        style: TextStyle(
                          color: _currentSectionIndex == index
                              ? Colors.blue
                              : Colors.black,
                        ),
                      ),
                    ),
                  );
                },
              ),
            ),
          ),
        ),
      ),
      body: Stack(children: [
        Column(
          children: <Widget>[
            // タブの表示

            // スクロール可能なコンテンツ
            Expanded(
                child: SingleChildScrollView(
              controller: _scrollController,
              child: Column(
                children: [
                  Container(
                    key: _sectionAKey,
                    height: 800,
                    color: Colors.lightBlue[100],
                    child: Center(child: Text('セクションA')),
                  ),
                  Container(
                    key: _sectionBKey,
                    height: 900,
                    color: Colors.yellow,
                    child: Center(child: Text('セクションB')),
                  ),
                  Container(
                    key: _sectionCKey,
                    height: 1000,
                    color: Colors.lightBlue[100],
                    child: Center(child: Text('セクションC')),
                  ),
                  Container(
                    key: _sectionDKey,
                    height: 1100,
                    color: Colors.blue,
                    child: Center(child: Text('セクションD')),
                  ),
                  Container(
                    key: _sectionFKey,
                    height: 1500,
                    color: Colors.red,
                    child: Column(
                      children: [
                        Center(child: Text('セクションF')),
                        Padding(padding: EdgeInsets.only(top: 250)),
                        Container(
                          key: _buttonKey,
                          height: 150,
                          color: Colors.green,
                          child: const Center(
                            child: Text(
                              '画面下部に固定されたコンテナ',
                              style: TextStyle(
                                color: Colors.white,
                                fontSize: 18.0,
                              ),
                            ),
                          ),
                        )
                      ],
                    ),
                  ),
                ],
              ),
            )),
          ],
        ),
        Positioned(
          bottom: 0, // 画面の下部に配置
          left: 0, // 左側に配置することもできます
          right: 0, // 右側に配置することもできます
          child: Visibility(
            visible: _itemVisibility,
            child: Container(
              color: Colors.green,
              height: 150, // 固定コンテナの高さ
              // ここにコンテンツを追加
              child: const Center(
                child: Text(
                  '画面下部に固定されたコンテナ',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 18.0,
                  ),
                ),
              ),
            ),
          ),
        ),
      ]),
    );
  }
}

▪︎実装するにあたり検討したことや気づいた点。

・セクションの高さは全て一定ではなくAPIで情報取得した時、情報量に対して不均一だよね。
・ん?最後のセクション高さ全然なかったらスクロールしないよねー。画面半分の高さとか追加しないとね。※未実装

良かったらご利用ください!

Discussion