Flutter SearchBar: Um guia definitivo

Tempo de leitura: 5 minutes

Introdução

No âmbito do desenvolvimento de aplicativos móveis, um recurso de pesquisa bem projetado não é apenas uma conveniência – é um componente vital que aumenta significativamente o envolvimento e a acessibilidade do usuário. Isso é particularmente verdadeiro para aplicativos que lidam com dados extensos. O Flutter, com sua crescente popularidade entre os desenvolvedores de aplicativos móveis, destaca-se como uma excelente estrutura para a criação de aplicativos compilados nativamente. Neste artigo, embarcaremos em uma jornada para projetar um widget de pesquisa versátil no Flutter, culminando na criação de um widget genérico semelhante ao CustomSearchDelegate. Usaremos o método showSearch para implementar a pesquisa. Primeiro, criaremos uma barra de pesquisa básica e, depois, a personalizaremos para que possa ser usada em qualquer projeto.

 

showSearch

ShowSearch é um método na biblioteca de materiais do Flutter. Por isso, ele pode ser acessado de qualquer lugar na sua árvore de widgets.

Future<T?> showSearch<T>({
  required BuildContext context,
  required SearchDelegate<T> delegate,
  String? query = '',
  bool useRootNavigator = false,
})

Esse método requer um BuildContext e um SearchDelegate, que é uma classe abstrata que recebe um parâmetro do tipo T.

Exemplo.

showSearch(context: context, delegate: CustomSearchDelegate());

Portanto, para implementar uma funcionalidade de pesquisa, precisamos apenas criar uma classe que estenda um SearchDelegate. Vamos ver como isso pode ser feito.

class CustomSearchDelegate extends SearchDelegate {
  @override
  List<Widget>? buildActions(BuildContext context) {
    // TODO: implement buildActions
    throw UnimplementedError();
  }

  @override
  Widget? buildLeading(BuildContext context) {
    // TODO: implement buildLeading
    throw UnimplementedError();
  }

  @override
  Widget buildResults(BuildContext context) {
    // TODO: implement buildResults
    throw UnimplementedError();
  }

  @override
  Widget buildSuggestions(BuildContext context) {
    // TODO: implement buildSuggestions
    throw UnimplementedError();
  }

}

O SearchDelegate exige a implementação de 4 métodos.

  • buildActions: Gerencia ações como a limpeza da consulta de pesquisa. Assemelha-se ao parâmetro de ação do AppBar.
  • buildLeading: Normalmente, inclui um botão de voltar para navegação.
  • buildResults: Exibe os resultados da pesquisa.
  • buildSuggestions: Oferece sugestões à medida que os usuários digitam suas consultas.

Vamos implementar uma pesquisa básica usando esse delegado.

class CustomSearchDelegate extends SearchDelegate {
  List<String> searchables = List.generate(100, (index) => 'Item ${index + 1}');

  @override
  List<Widget>? buildActions(BuildContext context) {
    return [
      IconButton(
        onPressed: () {
          query = '';
        },
        icon: const Icon(Icons.clear),
      ),
    ];
  }

  @override
  Widget? buildLeading(BuildContext context) {
    return IconButton(
      onPressed: () {
        close(context, []);
      },
      icon: Icon(
        Platform.isAndroid ? Icons.arrow_back : Icons.arrow_back_ios,
        size: 22,
      ),
    );
  }

  @override
  Widget buildResults(BuildContext context) {
    return SuggestionOrResultWidget(searchables: searchables, query: query);
  }

  @override
  Widget buildSuggestions(BuildContext context) {
    return SuggestionOrResultWidget(searchables: searchables, query: query);
  }
}
class SuggestionOrResultWidget extends StatelessWidget {
  const SuggestionOrResultWidget({
    super.key,
    required this.searchables,
    required this.query,
  });

  final List<String> searchables;
  final String query;

  @override
  Widget build(BuildContext context) {
    final List<String> suggestions = query.isEmpty
        ? searchables
        : searchables.where((element) => element.toLowerCase().contains(query.toLowerCase())).toList();

    if (suggestions.isEmpty) return const NoResultWidget();

    return ListView.separated(
      itemBuilder: (context, index) => ListTile(
        title: Text(suggestions[index]),
      ),
      separatorBuilder: (context, index) => const Divider(),
      itemCount: suggestions.length,
    );
  }
}
class NoResultWidget extends StatelessWidget {
  const NoResultWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('No Result Found'),
    );
  }
}

O resultado terá a seguinte aparência

Personalização do ThemeData de sua página de pesquisa AppBar

Uma coisa que você pode notar é que, quando a página de pesquisa é exibida, o tema não é o mesmo que o tema do aplicativo. Você pode ver isso especificamente nas cores da AppBar. O código abaixo apenas passará o ThemeData de seu aplicativo diretamente para a AppBar na página de pesquisa.

class CustomSearchDelegate extends SearchDelegate {
  List<String> searchables = List.generate(100, (index) => 'Item ${index + 1}');

  @override
  ThemeData appBarTheme(BuildContext context) {
    return ThemeData(
      // Customize appbar theme
      appBarTheme: const AppBarTheme(
        backgroundColor: Colors.blue,
        foregroundColor: Colors.white,
      ),
      // Customize input decoration theme
      inputDecorationTheme: const InputDecorationTheme(
        isDense: true,
        isCollapsed: true,
        contentPadding: EdgeInsets.only(left: 12, top: 6, bottom: 6),
        enabledBorder: OutlineInputBorder(
            borderSide: BorderSide(
          color: Colors.white,
        )),
        focusedBorder: OutlineInputBorder(
            borderSide: BorderSide(
          color: Colors.white,
        )),
        border: OutlineInputBorder(
          borderSide: BorderSide(
            color: Colors.white,
          ),
        ),
      ),
    );
  }

Criando um widget de pesquisa genérico:

Passando para uma implementação mais avançada, vamos nos aprofundar no CustomSearchDelegate . Esse widget genérico se destaca por sua reutilização e adaptabilidade em diferentes tipos de dados.

/// Generic Search Delegate which can be used anywhere
class CustomSearchDelegate<T> extends SearchDelegate<List<T>> {
  CustomSearchDelegate({
    required this.searchables,
    required this.suggestionOrResult,
    required this.itemMatcher,
    this.onTap,
  });
  
  // Items to be searched
  final List<T> searchables;
  
  // Widget that needs to be displayed when getting suggestion or result. 
  // If you want sepearate widget for suggestion and result, you can add
  // 2 arguments, suggestionWidget and resultWidget to build differently.
  final Widget Function(List<T>, String) suggestionOrResult;

  // Query against which matching will run
  final bool Function(T item, String query) itemMatcher;

  // Callback when item is tapped
  final ValueChanged<T>? onTap;

  @override
  List<Widget>? buildActions(BuildContext context) {
    return [
      IconButton(
        onPressed: () {
          query = '';
        },
        icon: const Icon(Icons.clear),
      ),
    ];
  }

  @override
  Widget buildLeading(BuildContext context) {
    return IconButton(
      onPressed: () {
        close(context, []);
      },
      icon: Icon(
        Platform.isAndroid ? Icons.arrow_back : Icons.arrow_back_ios,
        size: 22,
      ),
    );
  }

  @override
  Widget buildResults(BuildContext context) {
    return _buildSuggestionOrResult();
  }

  @override
  Widget buildSuggestions(BuildContext context) {
    if (searchables.isEmpty) return const SizedBox();
    return _buildSuggestionOrResult();
  }

  Widget _buildSuggestionOrResult() {
    final List<T> suggestionList =
        query.isEmpty ? searchables : searchables.where((item) => itemMatcher(item, query)).toList();

    if (suggestionList.isEmpty) {
      return NoResultFoundWidget();
    }

    return suggestionOrResult(suggestionList, query);
  }

}

Esse delegado genérico pode ser usado como:

showSearch(
  context: context,
  delegate: CustomSearchDelegate<List<String>>(
  searchables: searchables,
  suggestionOrResult: (List<String> suggestions,String query) => ListWidget(
    suggestionsList: searchables,
    query: query,
   ),
   itemMatcher: (String item,String query) => item.toLowerCase().contains(query.toLowerCase()),
  ),
)

Bônus

Se você quiser destacar a letra digitada na lista de sugestões e rolar até o resultado, poderá usar o seguinte widget.

class ListWidget extends StatefulWidget {
  const ListWidget({
    super.key,
    required this.suggestionsList,
    required this.query,
  });

  final List<String> suggestionsList;
  final String query;

  @override
  State<ListWidget> createState() => _ListWidgetState();
}

class _ListWidgetState extends State<ListWidget> {
  late List<String> filteredList;
  final ScrollController scrollController = ScrollController();

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

  @override
  void didUpdateWidget(ListWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.query != oldWidget.query) {
      filterList();
    }
  }

  void filterList() {
    filteredList =
        widget.suggestionsList.where((item) => item.toLowerCase().contains(widget.query.toLowerCase())).toList();

    if (filteredList.isNotEmpty) {
      if (filteredList.isNotEmpty) {
        WidgetsBinding.instance.addPostFrameCallback((_) => scrollToFirstMatch());
      }
    }
  }

  void scrollToFirstMatch() {
    int index = widget.suggestionsList.indexOf(filteredList.first);
    if (index != -1 && scrollController.hasClients) {
      scrollController.animateTo(
        index * 32.0, // Assuming each item has a height of 32
        duration: const Duration(milliseconds: 100),
        curve: Curves.easeInOut,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      controller: scrollController,
      itemCount: filteredList.length,
      itemBuilder: (context, index) {
        final String suggestionText = filteredList[index];
        return ListTile(
          title: RichText(
            text: highlightMatch(suggestionText, widget.query),
          ),
          onTap: () {
            // Your onTap functionality here
          },
        );
      },
      separatorBuilder: (context, index) => const Divider(),
    );
  }

  TextSpan highlightMatch(String text, String query) {
    if (query.isEmpty || !text.toLowerCase().contains(query.toLowerCase())) {
      return TextSpan(
        text: text,
        style: const TextStyle(color: Colors.black),
      );
    }

    List<TextSpan> spans = [];
    int start = 0;
    int indexOfHighlight = text.toLowerCase().indexOf(query.toLowerCase());

    while (indexOfHighlight != -1) {
      spans.add(TextSpan(
        text: text.substring(start, indexOfHighlight),
        style: const TextStyle(color: Colors.black),
      ));
      spans.add(TextSpan(
        text: text.substring(indexOfHighlight, indexOfHighlight + query.length),
        style: const TextStyle(backgroundColor: Colors.yellow, color: Colors.black),
      ));

      start = indexOfHighlight + query.length;
      indexOfHighlight = text.toLowerCase().indexOf(query.toLowerCase(), start);
    }

    spans.add(TextSpan(
      text: text.substring(start),
      style: const TextStyle(color: Colors.black),
    ));
    return TextSpan(children: spans);
  }
}

 

Conclusão

Um recurso de pesquisa eficaz é um elemento crítico no design de aplicativos móveis, impactando a satisfação e o envolvimento do usuário. Esse CustomSearchDelegate é uma prova disso, oferecendo um modelo para a criação de funcionalidades de pesquisa personalizáveis e versáteis em aplicativos Flutter.

Obrigado pela leitura. Boa programação 🙂