【Flutter】DraggableScrollableSheetを使ってみた

# はじめに
普段、使ったことないWidgetを使って記事を書こうと思いましたので、一度も使ったことがない`DraggableScrollableSheet`を使ってコードを書いてみました。

公式の記事と動画の説明がわかりやすかったのでURLを貼っておきます。

https://api.flutter.dev/flutter/widgets/DraggableScrollableSheet-class.html

https://youtu.be/Hgw819mL_78

# 作ったもの
ショッピングアプリで、商品リストを見ながら、カートの画面を見れるという画面です。

![sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2670089/324cf0ef-3f10-c13a-7d55-a7cdea4ffd8d.gif)

# コード
作ったコードを貼り付けておきます。
表示部分しか作ってないので、商品を追加したり、削除したりというのはできません。
`DraggableScrollableSheet`の使い方については次の章で説明します。

```dart:product_list_page.dart
// 表示している画面
class ProductListPage extends StatelessWidget {
  const ProductListPage();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Product List'),
      ),
      body: Container(
        padding: const EdgeInsets.all(16),
        child: Stack(
          children: [
            // 商品一覧表示
            const ProductListView(),
            // カートを表示
            DraggableScrollableSheet(
              initialChildSize: 0.1,
              minChildSize: 0.1,
              maxChildSize: 0.9,
              builder:
                  (BuildContext context, ScrollController scrollController) {
                // カートの商品一覧を表示
                return Container(
                  decoration: const BoxDecoration(
                    color: Color.fromARGB(255, 218, 243, 255),
                    borderRadius: BorderRadius.only(
                      topLeft: Radius.circular(16),
                      topRight: Radius.circular(16),
                    ),
                  ),
                  // カート商品表示のタイトルとアイテム一覧を表示
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      //　カート商品表示のタイトル
                      const SizedBox(
                        height: 40,
                        child: Center(
                          child: Text(
                            "Cart",
                            style: TextStyle(
                              fontSize: 24,
                              fontWeight: FontWeight.w700,
                            ),
                          ),
                        ),
                      ),
                      //　カートのアイテム一覧を表示
                      Expanded(
                        child: ListView.builder(
                          controller: scrollController,
                          itemCount: productsInCart.length,
                          itemBuilder: (context, index) {
                            final product = productsInCart[index];
                            return Padding(
                              padding: const EdgeInsets.all(8.0),
                              child: CartItem(
                                title: product.name,
                                price: product.price,
                              ),
                            );
                          },
                        ),
                      ),
                    ],
                  ),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

//　商品一覧表示の個別のアイテム
class ProductItem extends StatelessWidget {
  final String title;
  final String description;
  final int price;
  const ProductItem({
    required this.title,
    required this.description,
    required this.price,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          flex: 3,
          child: Container(
            height: 100,
            color: Colors.grey.withOpacity(0.7),
            child: const Center(
                child: Text(
              "Image",
              style: TextStyle(
                color: Colors.white,
              ),
            )),
          ),
        ),
        const SizedBox(
          width: 8,
        ),
        Expanded(
          flex: 7,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              ProductTitleText(title),
              ProductDescriptionText(description),
              ProductPriceText(price),
            ],
          ),
        ),
      ],
    );
  }
}

// カート一覧に表示する個別のアイテム
class CartItem extends StatelessWidget {
  final String title;
  final int price;

  const CartItem({
    required this.title,
    required this.price,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          flex: 3,
          child: Container(
            height: 100,
            color: Colors.grey.withOpacity(0.7),
            child: const Center(
                child: Text(
              "Image",
              style: TextStyle(
                color: Colors.white,
              ),
            )),
          ),
        ),
        const SizedBox(
          width: 8,
        ),
        Expanded(
          flex: 7,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              ProductTitleText(title),
              ProductPriceText(price),
            ],
          ),
        ),
        IconButton(
          onPressed: () {},
          icon: const Icon(
            Icons.delete,
          ),
        ),
      ],
    );
  }
}

//　商品一覧表示
class ProductListView extends StatelessWidget {
  const ProductListView({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: products.length,
      itemBuilder: (context, index) {
        final product = products[index];
        return Padding(
          padding: const EdgeInsets.only(top: 8, bottom: 8),
          child: ProductItem(
            title: product.name,
            description: product.description,
            price: product.price,
          ),
        );
      },
    );
  }
}

//　ProductItemのタイトルに使うテキスト
class ProductTitleText extends StatelessWidget {
  final String title;
  const ProductTitleText(this.title, {super.key});

  @override
  Widget build(BuildContext context) {
    return Text(
      title,
      textAlign: TextAlign.start,
      style: const TextStyle(
        fontSize: 20,
        fontWeight: FontWeight.w700,
      ),
    );
  }
}

//　ProductItemの説明に使うテキスト
class ProductDescriptionText extends StatelessWidget {
  final String description;
  const ProductDescriptionText(this.description, {super.key});

  @override
  Widget build(BuildContext context) {
    return Text(
      description,
      textAlign: TextAlign.start,
      style: const TextStyle(
        fontSize: 16,
      ),
    );
  }
}

// ProductItemの価格に使うテキスト
class ProductPriceText extends StatelessWidget {
  final int price;
  const ProductPriceText(this.price, {super.key});

  @override
  Widget build(BuildContext context) {
    return Text(
      "￥$price",
      textAlign: TextAlign.start,
      style: const TextStyle(
        fontSize: 20,
        fontWeight: FontWeight.w700,
        color: Colors.red,
      ),
    );
  }
}

```

```dart:resources.dart
// 商品データのモデルクラス
class Product {
  String name;
  String description;
  int price;

  Product({required this.name, required this.description, required this.price});
}

//　商品一覧に表示する商品リスト
List<Product> products = [
  Product(
      name: "掃除機",
      description: "スムーズな吸引力と高効率なフィルターが特徴。小型で軽量なので、家庭のあらゆる場所で使用できます。",
      price: 8000),
  Product(
      name: "よく吸う掃除機",
      description:
          "パワフルなモーターと高性能のフィルターを搭載し、毛やダストを徹底的に吸い取ります。コンパクトなデザインで、収納にも便利です。",
      price: 15000),
  Product(
      name: "エアクリーナー",
      description:
          "空気中の微粒子を取り除くための高性能フィルターを搭載した空気清浄機。コンパクトで静音なので、寝室やオフィスでも使用できます。",
      price: 25000),
  Product(
      name: "コーヒーメーカー",
      description: "豆から挽いたコーヒー豆で本格的なコーヒーを簡単に作れる。自動シャットオフ機能付きで、安心して使用できます。",
      price: 12000),
  Product(
      name: "電気ケトル",
      description:
          "ワンタッチでお湯を沸かすことができる電気ケトル。高速で沸騰するので、忙しい朝にも便利です。コンパクトなデザインで、省スペースに収納できます。",
      price: 5000),
  Product(
      name: "10000mAhモバイルバッテリー",
      description:
          "スマートフォンやタブレットの充電に使用できる10000mAhのモバイルバッテリー。コンパクトなデザインで、持ち運びにも便利です。",
      price: 3000),
  Product(
      name: "照明器具",
      description: "優れた省エネ性能を持ち、快適な明るさを提供する照明器具。調光機能があり、ライトの明るさを自由に調整できます。",
      price: 6000),
  Product(
      name: "空気清浄機",
      description:
          "高性能のフィルターで空気中の花粉やほこりを取り除く空気清浄機。赤ちゃんやアレルギー持ちの方にもおすすめ、使い勝手の良いデザインで、リビングや寝室などのあらゆる場所で使用できます。",
      price: 20000),
  Product(
      name: "Bluetoothスピーカー",
      description:
          "高品質な音質とワイヤレス接続による快適な操作性が特徴のBluetoothスピーカー。コンパクトで持ち運びに便利なデザインです。",
      price: 7000),
  Product(
      name: "キッチンスケール",
      description:
          "正確な計量ができるキッチンスケール。液晶ディスプレイにより、計量結果を見やすく表示します。コンパクトなデザインで、キッチンの収納にも適しています。",
      price: 4000),
  Product(
      name: "ノイズキャンセリングヘッドフォン",
      description:
          "ノイズキャンセリング機能により、周囲の騒音を最大限に軽減するヘッドフォン。高音質なサウンドで、音楽や映画をより楽しめます。",
      price: 25000),
];

//　カートに入っている商品リスト
List<Product> productsInCart = List.generate(10, (index) => products[index]);

```

# DraggableScrollableSheetの使い方
前章のコードでは要点を掴みにくいと思いますので、`DraggableScrollableSheet`に焦点を当てたコードを掲載します。

```dart
DraggableScrollableSheet(
  // 初期の表示割合
  initialChildSize: 0.2,
  // 最小の表示割合
  minChildSize: 0.1,
  // 最大の表示割合
  maxChildSize: 0.9,
  // ドラッグを離した時に一番近いsnapSizeになるか
  snap: true,
  // snapで止める時の割合
  snapSizes: const [0.1, 0.5, 0.9],
  builder: (BuildContext context, ScrollController scrollController) {
    //　表示したいWidgetを返す
    return Container();
  },
),
```

コード内の下記の引数は省略可能です
* `initialChildSize`(デフォルトは0.5)
* `minChildSize`(デフォルトは0.25)
* `maxChildSize`(デフォルトは1.0)
* `snap`(デフォルトはfalse)
* `snapSized`(nullable)

`DraggableScrollableSheet`を使用する際の注意点は、`builder`内で`ScrollController`を使用しないと、シートは`initialChildSize`のままになるという点です。
したがって、シートのサイズをドラッグ操作したい場合、`builder`で返すWidgetには`ListView`や`SingleChildScrollView`を含めるようにしましょう。

# 他の用途
今回は、商品一覧とカート内の商品を一画面で見るという用途で使用してみました。
他に使えそうな用途としては、以下が挙げられると思います
* 一覧画面内で、画面遷移せずに詳細を見たい場合
詳細の表示に`DraggableScrollableSheet`を使うことで遷移せずに詳細が確認できます。
* 設定画面で設定を変更した時のフィードバックを即座に確認したい場合
例えば、設定画面でメイン画面のアイコンの大きさを変えれると仮定して、設定画面とメイン画面が別画面だと、設定を変えた時に、メイン画面に戻るまでどのような画面になるかがわかりませんが、メイン画面に`DraggableScrollableSheet`を使って設定できるようにすると、反映が即座に確認できます。

# おわりに
使う場面は少ないかもしれませんが、お役に立てれば幸いです。