Conhecendo o widget DraggableScrollableSheet do Flutter

Motivação

Me foi passada uma tarefa no trabalho para desenvolver um componente no Flutter que é bastante utilizado em outros apps também, mas tinha um pequeno problema: eu não fazia nem ideia de qual era o nome desse componente e de como eu iria implementar isso no app. Levei pouco tempo para descobrir o nome dele (DraggableScrollableSheet) e já comecei a implementar no app e cuidar das particularidades que eu precisava, que vou contar nesse post.

Entendendo o componente DraggableScrollableSheet e definindo as particularidades que eu precisava

Vocês com certeza já devem ter utilizado o Google Maps, correto? Então, esse é um dos apps mais famosos que contam com um componente nesse estilo aqui:

gif

Sim, esse não é o google maps, mas peguei o primeiro exemplo que achei aqui para ilustrar mais ou menos o que eu precisava. Então, vamos a lista de requisitos:

  • Um componente que ficasse "grudado" na parte de baixo da tela e NUNCA ficasse completamente escondido;
  • Um componente que se expandisse em direção ao topo da tela quando eu clicasse em um botão ou arrastasse ele em direção ao topo;
  • Um componente que o conteúdo dele fosse "scrolllavel" para visualização das informações;

Com esses requisitos, iriamos tender para duas soluções dentro do flutter: BottomSheet ou DraggableScrollableSheet. Acabei ficando com o DraggableScrollableSheet pelo simples motivo de que com ele é possível não deixa-lo completamente escondido, diferente do outro.

Com o componente escolhido, vamos para a implementação!

Implementando o DraggableScrollableSheet

Necessidade do widget Stack

Se você for na documentação oficial desse widget, vai achar esse código:

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DraggableScrollableSheet'),
      ),
      body: SizedBox.expand(
        child: DraggableScrollableSheet(
          builder: (BuildContext context, ScrollController scrollController) {
            return Container(
              color: Colors.blue[100],
              child: ListView.builder(
                controller: scrollController,
                itemCount: 25,
                itemBuilder: (BuildContext context, int index) {
                  return ListTile(title: Text('Item $index'));
                },
              ),
            );
          },
        ),
      ),
    );
  }
}

que mostra o básico, mas não mostra como podemos utilizar ele com outros conteúdos na tela. Para isso, vamos precisar da ajuda do widget Stack.Não vou entrar em muitos detalhes aqui, mas o Stack basicamente permite que seus filhos se sobreponham um sobre o outro (tipo o position: absolute do css). E o componente DraggableScrollableSheet é inteligente o suficiente para saber que quando o pai dele for um Stack, ele deve ficar colado na base da tela. Com isso, nosso código atualizado ficaria assim:

import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DraggableScrollableSheet'),
      ),
      body: Stack( // <-- COLOCANDO STACK COMO PAI
        children: [
          Text("Filho de exemplo"), // <-- EXEMPLO DE CONTEÚDO 
          DraggableScrollableSheet(
            builder: (BuildContext context, ScrollController scrollController) {
              return Container(
                color: Colors.blue[100],
                child: ListView.builder(
                  controller: scrollController,
                  itemCount: 25,
                  itemBuilder: (BuildContext context, int index) {
                    return ListTile(title: Text('Item $index'));
                  },
                ),
              );
            },
          ),
        ],
      ),
    );
  }
}

com isso, podemos colocar mais elementos na nossa tela e prosseguir com o desenvolvimento. No momento, está assim:

flutter2.gif

Construindo o header do DraggableScrollableSheet

Bom, agora que já temos algo funcional que cumpre os nosso requisitos básicos de ficar preso na parte de baixo da tela e conseguir se expandir em direção ao topo, vamos ao header do nosso componente. O header é uma parte bem simples, vai conter basicamente um resumo do que o usuário está vendo na tela e um botão para ele clicar e poder expandir completamente ou voltar ao seu estado inicial (aparecendo só uma parte). Sabendo disso, o nosso código com o header ficará algo assim:

import 'dart:math';

import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DraggableScrollableSheet'),
      ),
      body: Stack(
        children: [
          Text("Filho de exemplo"),
          DraggableScrollableSheet(
            initialChildSize: 0.12, // <-- NOVAS PROPRIEDADES
            minChildSize: 0.12,
            maxChildSize: 0.8,
            builder: (BuildContext context, ScrollController scrollController) {
              return Container(
                color: Colors.blue,
                child: SingleChildScrollView( // <-- MUDANÇA DO WIDGET DE SCROLL
                  controller: scrollController,
                  child: Column(
                    children: [
                      Container(
                        padding:
                            const EdgeInsets.only(right: 18, left: 18, top: 12),
                        child: Row( // <-- ROW QUE SERÁ O HEADER
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
                          children: [
                            Column( // <-- COLUNA DE RESUMO
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: const [
                                Text(
                                  "Resumo de algo aqui",
                                  style: TextStyle(color: Colors.white),
                                ),
                                Text(
                                  "Descrição do resumo aq",
                                  style: TextStyle(
                                    color: Colors.white,
                                    fontWeight: FontWeight.w700,
                                  ),
                                )
                              ],
                            ),
                            OutlinedButton( // <-- BOTÃO PARA EXPANDIR E ENCOLHER
                              onPressed: () {},
                              style: OutlinedButton.styleFrom(
                                side: const BorderSide(
                                    width: 1.0, color: Colors.white),
                                shape: const StadiumBorder(),
                              ),
                              child: Row(
                                children: [
                                  const Text(
                                    "Ver mais",
                                    style: TextStyle(color: Colors.white),
                                  ),
                                  const SizedBox(width: 5),
                                  Transform.rotate(
                                    angle: -90 * pi / 180,
                                    child: const Icon(
                                      Icons.arrow_forward_ios_rounded,
                                      size: 18.0,
                                      color: Colors.white,
                                    ),
                                  ),
                                ],
                              ),
                            )
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
              );
            },
          ),
        ],
      ),
    );
  }
}

Tivemos uma certa quantidade de mudanças, sendo:

  • Propriedades de initialChildSize, minChildSize e maxChildSize. O nome já é bem descritivo, sendo a altura inicial do widget, mínima e máxima respectivamente. Esse valor pode variar de 0 até 1, correspondendo a % da altura do componente pai.

  • Mudança para de ListView para SingleChildScrollView. Isso aqui não afeta tanto o nosso desenvolvimento, eu resolvi mudar pois fazia mais sentido para o que eu estava desenvolvendo.

  • Componente Row que será o nosso header com o resumo do que está sendo apresentado na tela e com o botão para expandir e encolher o nosso widget (cuidaremos do comportamento desse botão mais tarde).

Com isso, teremos o seguinte resultado:

flutter3.gif

Com isso feito, vamos ao funcionamento do botão.

Desenvolvendo o funcionamento do botão de expandir e encolher

Já que esse botão é de expandir e encolher, vamos precisar de 2 coisas para termos um funcionamento completo:

  • Guardar se o nosso widget (DraggableScrollableSheet) está aberto ou fechado;
  • Quando clicarmos no botão, ele expandir ou encolher o nosso widget;

Cuidaremos dos dois em seguida.

Se você conhece o básico de flutter, já sabe que a primeira coisa que vamos ter que fazer é transformar o nosso componente de Stateless para Stateful (o que nos permite guardar estado e ter acesso aos life cyclie methods de um widget, vamos precisar de ambos). Se você está no VSCode e clicar com CTRl + . em StatelessWidget, verá que aparece uma opção de você transformar em Stateful, assim:

image.png

Após a conversão, ficará assim:

import 'dart:math';

import 'package:flutter/material.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DraggableScrollableSheet'),
      ),
      body: Stack(
        children: [
          Text("Filho de exemplo"),
          DraggableScrollableSheet(
            initialChildSize: 0.12,
            minChildSize: 0.12,
            maxChildSize: 0.8,
            builder: (BuildContext context, ScrollController scrollController) {
              return Container(
                color: Colors.blue,
                child: SingleChildScrollView(
                  controller: scrollController,
                  child: Column(
                    children: [
                      Container(
                        padding:
                            const EdgeInsets.only(right: 18, left: 18, top: 12),
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
                          children: [
                            Column(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: const [
                                Text(
                                  "Resumo de algo aqui",
                                  style: TextStyle(color: Colors.white),
                                ),
                                Text(
                                  "Descrição do resumo aq",
                                  style: TextStyle(
                                    color: Colors.white,
                                    fontWeight: FontWeight.w700,
                                  ),
                                )
                              ],
                            ),
                            OutlinedButton(
                              onPressed: () {},
                              style: OutlinedButton.styleFrom(
                                side: const BorderSide(
                                    width: 1.0, color: Colors.white),
                                shape: const StadiumBorder(),
                              ),
                              child: Row(
                                children: [
                                  const Text(
                                    "Ver mais",
                                    style: TextStyle(color: Colors.white),
                                  ),
                                  const SizedBox(width: 5),
                                  Transform.rotate(
                                    angle: -90 * pi / 180,
                                    child: const Icon(
                                      Icons.arrow_forward_ios_rounded,
                                      size: 18.0,
                                      color: Colors.white,
                                    ),
                                  ),
                                ],
                              ),
                            )
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
              );
            },
          ),
        ],
      ),
    );
  }
}

Com isso, podemos colocar nosso estado bem no começo da classe _HomePageState, bem assim:

class _HomePageState extends State<HomePage> {
  bool isBottomSheetOpen = false;

// Resto do código que já conhecemos aqui ...

Perfeito, agora temos um meio de guardar se ele ta aberto, só colocar uma função de onPressed no botão para abrir ou fechar o widget e mudar esse estado, correto?

ERRADO !

Atualmente já temos um modo de abrir o nosso widget: arrastando ele pra cima! Se mudarmos esse estado somente quando um botão for clicado, se abrirmos ele arrastando, o nosso estado não vai saber que ele está aberto e continuará mostrando o texto do botão como se ele estivesse fechado. Para resolver isso vamos precisar do DraggableScrollableController. Esse controller vai nos permitir fazer duas coisas: abrir o widget quando o botão for clicado e saber em que altura o nosso widget está enquanto ele está sendo arrastando (permitindo a gente saber se o widget está encolhido ou totalmente aberto). A inicialização dele é bem simples, vamos colocar logo abaixo do nosso estado, assim:

class _HomePageState extends State<HomePage> {
  bool isBottomSheetOpen = false;
DraggableScrollableController dragController =
      DraggableScrollableController();

// Resto do código que já conhecemos aqui ...

e claro, vamos atrelar ele ao nosso widget:

// Resto do código que já conhecemos aqui ...
DraggableScrollableSheet(
    initialChildSize: 0.12,
    minChildSize: 0.12,
    maxChildSize: 0.8,
    controller: dragController,
    builder: (BuildContext context, ScrollController scrollController) {
// Resto do código que já conhecemos aqui ...

Agora que já temos o controller inicializado e atrelado ao nosso widget, precisamos criar uma função para abrir e fechar, atrelar ao nosso botão e mudar o texto e ícone dependendo do estado atual, ficando assim:

  • A função:
    onToggleBottomSheet() {
     if (isBottomSheetOpen) {
       dragController.animateTo(0.12,
           duration: const Duration(milliseconds: 200), curve: Curves.ease);
       isBottomSheetOpen = false;
     } else {
       dragController.animateTo(0.8,
           duration: const Duration(milliseconds: 200), curve: Curves.ease);
       isBottomSheetOpen = true;
     }
    }
    
  • A atribuição ao botão:
OutlinedButton(
  onPressed: onToggleBottomSheet,
  • A mudança do texto e ícone refletindo o estado:
OutlinedButton(
      onPressed: onToggleBottomSheet,
      style: OutlinedButton.styleFrom(
      side: const BorderSide(
          width: 1.0, color: Colors.white),
      shape: const StadiumBorder(),
      ),
      child: Row(
        children: [
          Text(
            isBottomSheetOpen
                ? "Ver menos"
                : "Ver mais",
            style: const TextStyle(
                color: Colors.white),
          ),
          const SizedBox(width: 5),
          Transform.rotate(
            angle: isBottomSheetOpen
                ? 90 * pi / 180
                : -90 * pi / 180,
            child: const Icon(
              Icons.arrow_forward_ios_rounded,
              size: 18.0,
              color: Colors.white,
            ),
          ),
        ],
      ),
)

Com isso feito, teremos o seguinte resultado em tela:

flutter4.gif

Perfeito! Nosso botão está mudando o texto de acordo com o nosso estado e o nosso widget está abrindo ou fechando ao clicar no botão, mas ainda temos um problema... Se eu abrir o widget arrastando ele para cima, o nosso estado não fica sabendo disso e causando isso aqui:

flutter5.gif

Por sorte isso é fácil de resolver com o nosso controller, vamos precisar colocar um listener nele para toda hora que a altura do nosso widget mudar na tela ele nos avisar e podermos saber se ele está fechado ou aberto enquanto o nosso usuário arrasta. Vamos precisar adicionar esse listener quando o widget é montado na tela, ou seja, no método initState do nosso widget. Esse é o resultado:

class _HomePageState extends State<HomePage> {
  bool isBottomSheetOpen = false;
  DraggableScrollableController dragController =
      DraggableScrollableController();

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

    dragController.addListener(
      () {
        if (dragController.size > 0.12 && isBottomSheetOpen == false) {
          isBottomSheetOpen = true;
        }

        if (dragController.size == 0.12 && isBottomSheetOpen == true) {
          isBottomSheetOpen = false;
        }
      },
    );
  }

toda vez que a altura do nosso widget muda, ele chama esse listener verifica se o tamanho atual é maior que o mínimo (0.12) ou igual e muda o nosso estado de acordo com isso. Com isso, temos o resultado final:

flutter6.gif

Deixando o nosso header preso no topo do widget

Perfeito, fizemos tudo funcionar, mas ainda sim algo pode ser melhorado. Se adicionarmos mais conteúdo ao nosso widget, ele poderá scrollar mais ainda e teremos algo assim:

flutter7.gif

ou seja, nosso header esta subindo junto com todo conteúdo. Já que ele é um header com um botão de ação, o ideal seria ele ficar preso no topo enquanto scrollamos o resto do coteúdo, correto?

Para isso, vamos precisar do widget StickyHeader que adquirimos por meio do pacote sticky_headers. Vamos adiciona-lo nas nossas dependências:

dependencies:
  flutter:
    sdk: flutter
  sticky_headers: "^0.3.0"

A utilização dele é bem simples, levando apenas um argumento de header que é o que vai ficar preso e um content que é todo o resto, nos levando para esse código final:

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:sticky_headers/sticky_headers/widget.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  bool isBottomSheetOpen = false;
  DraggableScrollableController dragController =
      DraggableScrollableController();

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

    dragController.addListener(
      () {
        if (dragController.size > 0.12 && isBottomSheetOpen == false) {
          isBottomSheetOpen = true;
        }

        if (dragController.size == 0.12 && isBottomSheetOpen == true) {
          isBottomSheetOpen = false;
        }
      },
    );
  }

  onToggleBottomSheet() {
    if (isBottomSheetOpen) {
      dragController.animateTo(0.12,
          duration: const Duration(milliseconds: 200), curve: Curves.ease);
      isBottomSheetOpen = false;
    } else {
      dragController.animateTo(0.8,
          duration: const Duration(milliseconds: 200), curve: Curves.ease);
      isBottomSheetOpen = true;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DraggableScrollableSheet'),
      ),
      body: Stack(
        children: [
          Text("Filho de exemplo"),
          DraggableScrollableSheet(
            initialChildSize: 0.12,
            minChildSize: 0.12,
            maxChildSize: 0.8,
            controller: dragController,
            builder: (BuildContext context, ScrollController scrollController) {
              return Container(
                color: Colors.blue,
                child: SingleChildScrollView(
                  controller: scrollController,
                  child: StickyHeader( // <-- O WIDGET
                    header: Container(
                      padding:
                          const EdgeInsets.only(right: 18, left: 18, top: 12),
                      color: Colors.blue,
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        children: [
                          Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: const [
                              Text(
                                "Resumo de algo aqui",
                                style: TextStyle(color: Colors.white),
                              ),
                              Text(
                                "Descrição do resumo aq",
                                style: TextStyle(
                                  color: Colors.white,
                                  fontWeight: FontWeight.w700,
                                ),
                              )
                            ],
                          ),
                          OutlinedButton(
                            onPressed: onToggleBottomSheet,
                            style: OutlinedButton.styleFrom(
                              side: const BorderSide(
                                  width: 1.0, color: Colors.white),
                              shape: const StadiumBorder(),
                            ),
                            child: Row(
                              children: [
                                Text(
                                  isBottomSheetOpen ? "Ver menos" : "Ver mais",
                                  style: const TextStyle(color: Colors.white),
                                ),
                                const SizedBox(width: 5),
                                Transform.rotate(
                                  angle: isBottomSheetOpen
                                      ? 90 * pi / 180
                                      : -90 * pi / 180,
                                  child: const Icon(
                                    Icons.arrow_forward_ios_rounded,
                                    size: 18.0,
                                    color: Colors.white,
                                  ),
                                ),
                              ],
                            ),
                          )
                        ],
                      ),
                    ),
                    content: Padding(
                      padding: EdgeInsets.all(16),
                      child: Column(children: [
                        Text("Filho de exemplo"),
                      ]),
                    ),
                  ),
                ),
              );
            },
          ),
        ],
      ),
    );
  }
}

e esse resultado:

flutter8.gif

Conclusão

Por não ter achado muita coisa igual é com os outros widgets do flutter enquanto eu estava tendo que implementar esse, achei que seria legal trazer o desenvolvimento del aqui, ainda mais com esse controle por botão e sempre assistindo mudança de estado dele. Foi algo interessante de implementar e me fez conhecer ainda mais o flutter.

CÓDIGO FONTE COMPLETO AQUI.