Creating a Custom Floating Search Bar for your Flutter Desktop App

Siro DavesSiro Daves
7 min read

One thing you will struggle with when you start to build flutter desktop apps is the lack of widgets that support various functions like a floating search bar. A search widget does exist in the flutter Sdk using the SearchDelegate<T> class which is a Delegate for showSearch to define the content of the search page. However, this will open a whole page or screen which only works well for mobile devices.

I encountered this challenge while implementing a design for a client app that needed a search button that when had a focus showed a floating list on the top of other widgets and I just didn't realize how hard that was till I started to work on it. I had already implemented an impressive header widget as you can see below but add the searching functionality was what was now going to be giving me a splitting headache. I had to think differently about the whole widget tree which all along had been put up together nicely using a stack widget.

Stack(
    children: <Widget>[
      HeaderWidget(
        showGoBackBtn: true,
        items: searchItems,
        onHelp: goToHelp,
        onSettings: goToSettings,
        onLogOut: goToStart,
      ).height(120).elevation(5),
      contentWidget.center().padding(top: 90),
    ],
),
  1. Create a Stateful Search Widget

    The first thing I did was to separate the search widget in my header to a StatefulWidget that takes in three parameters: a list of search items, and two double values that represent the sizes of the left and right margins of the widget.

class SearchWidget extends StatefulWidget {
  final List<SearchItem> items;
  final double lMargin;
  final double rMargin;

  const SearchWidget(
      {super.key,
      required this.items,
      required this.lMargin,
      required this.rMargin});
  @override
  SearchWidgetState createState() => SearchWidgetState();
}
  1. Define the FocusNode

  • In the StudentSearchState class, TextEditingController is created to control the input field. A FocusNode is also created to track the focus state of the search bar.

  • The initState method sets the initial state of the filtered items to be the same as the input items. A listener is added to the FocusNode to track changes to the focus state.

  • The dispose method removes the listener from the FocusNode and disposes of it.

  • The onFocusChange method sets the state of the hasFocus variable to true or false depending on whether the search bar has focus.

class SearchWidgetState extends State<SearchWidget> {
  TextEditingController qryController = TextEditingController();
  final FocusNode focusNode = FocusNode();
  List<SearchItem> filtered = [];
  bool hasFocus = false;

  @override
  void initState() {
    super.initState();
    filtered = widget.items;
    focusNode.addListener(onFocusChange);
  }

  @override
  void dispose() {
    focusNode.removeListener(onFocusChange);
    focusNode.dispose();
    super.dispose();
  }

  void onFocusChange() {
    setState(() => hasFocus = focusNode.hasFocus);
  }
}
  1. Implement Methods to handle Value changes or Selections

  • The handleValueChanged method is called when the text in the search bar is changed. It filters the items in the input list to only those that contain the search query and updates the state of the filtered items.

  • The handleItemSelected method is called when a user selects an item from the search results. It updates the text in the search bar to match the selected item.

  void handleValueChanged(String query) {
    if (query.isNotEmpty) {
      setState(() {
        filtered = widget.items
            .where((e) => e.title!.toLowerCase().contains(query.toLowerCase()))
            .toList();
      });
    }
  }

  void handleItemSelected(SearchItem item) {
    setState(() => qryController.text = item.title!);
  }
  1. Customise your TextFormField Widget

    A searchContainer variable is created that contains a Container widget with a TextFormField that represents the search bar. The TextFormField has a prefixIcon of a search icon and a suffixIcon of a clear icon. The onChanged method of the TextFormField calls the handleValueChanged method.

var searchContainer = Container(
      decoration: const BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.all(Radius.circular(30)),
      ),
      child: TextFormField(
        autovalidateMode: AutovalidateMode.onUserInteraction,
        focusNode: focusNode,
        controller: qryController,
        decoration: InputDecoration(
          prefixIcon: const Icon(Icons.search),
          suffixIcon: InkWell(
            onTap: () => qryController.clear(),
            child: const Icon(Icons.clear, color: ThemeColors.primary),
          ),
          labelStyle: const TextStyle(fontSize: 18, color: ThemeColors.primary),
          isDense: true,
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(30),
            borderSide: const BorderSide(color: Colors.white),
          ),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(30),
            borderSide: const BorderSide(color: Colors.white),
          ),
        ),
        style: const TextStyle(fontSize: 18, color: ThemeColors.primary),
        textInputAction: TextInputAction.done,
        onChanged: (String query) => handleValueChanged(query),
      ),
    );
  1. Add a Container for the results

    A resultsContainer variable is created that contains a Container widget with a ListView.builder widget that displays the filtered items. The onPressed method of each RawMaterialButton item calls the handleItemSelected method.

var resultsContainer = Container(
      height: size.height / 2,
      margin: const EdgeInsets.only(top: 5),
      padding: const EdgeInsets.all(10),
      decoration: BoxDecoration(
        color: Colors.white,
        border: Border.all(color: Colors.grey),
        borderRadius: const BorderRadius.all(Radius.circular(Corners.s10)),
        boxShadow: const [
          BoxShadow(
            color: Colors.grey,
            spreadRadius: 1,
            blurRadius: 1,
            offset: Offset(0, 1),
          ),
        ],
      ),
      child: ListView.builder(
        itemExtent: 60,
        itemCount: filtered.length,
        itemBuilder: (ctx, index) {
          SearchItem item = filtered[index];
          return Padding(
            padding: const EdgeInsets.only(right: 5, bottom: 5),
            child: RawMaterialButton(
              fillColor: Colors.white.withOpacity(.75),
              focusElevation: 0,
              hoverColor: ThemeColors.accent,
              hoverElevation: 1,
              highlightElevation: 0,
              padding: const EdgeInsets.all(10),
              shape: const RoundedRectangleBorder(
                borderRadius: BorderRadius.all(Radius.circular(5)),
              ),
              onPressed: () => handleItemSelected(item),
              child: Align(
                alignment: Alignment.centerLeft,
                child: Text(
                  item.title!,
                  maxLines: 1,
                  style: TextStyles.Body1.size(20)
                      .bold
                      .textColor(ThemeColors.primary),
                ),
              ),
            ),
          );
        },
      ),
    );
  1. Put it all together

  • In the build method, the size of the screen is obtained using the MediaQuery class.

  • The build method returns a SizedBox with a Row widget that contains the searchContainer, resultsContainer (if hasFocus is true), and left and right margins.

  • The searchContainer and resultsContainer are contained in a Column widget, and the resultsContainer has a fixed height of half the screen height.

  • Finally, the Column widget is expanded to fill the remaining space in the Row widget.

return SizedBox(
      child: Row(
        children: [
          SizedBox(width: widget.lMargin),
          Column(
            children: [
              const SizedBox(height: 30),
              searchContainer,
              if (hasFocus) resultsContainer,
            ],
          ).expanded(),
          SizedBox(width: widget.rmargin),
        ],
      ),
    );

If we are to put the whole class together the code would be as below:

import 'package:flutter/material.dart';
import 'package:styled_widget/styled_widget.dart';

import '../../model/search_item.dart';
import '../../theme/theme_colors.dart';
import '../../theme/theme_styles.dart';

class SearchWidget extends StatefulWidget {
  final List<SearchItem> items;
  final double lMargin;
  final double rMargin;

  const SearchWidget(
      {super.key,
      required this.items,
      required this.lMargin,
      required this.rMargin});
  @override
  SearchWidgetState createState() => SearchWidgetState();
}

class SearchWidgetState extends State<SearchWidget> {
  TextEditingController qryController = TextEditingController();
  final FocusNode focusNode = FocusNode();
  List<SearchItem> filtered = [];
  bool hasFocus = false;

  @override
  void initState() {
    super.initState();
    filtered = widget.items;
    focusNode.addListener(onFocusChange);
  }

  @override
  void dispose() {
    focusNode.removeListener(onFocusChange);
    focusNode.dispose();
    super.dispose();
  }

  void onFocusChange() {
    setState(() => hasFocus = focusNode.hasFocus);
  }

  void handleValueChanged(String query) {
    if (query.isNotEmpty) {
      setState(() {
        filtered = widget.items
            .where((e) => e.title!.toLowerCase().contains(query.toLowerCase()))
            .toList();
      });
    }
  }

  void handleItemSelected(SearchItem item) {
    setState(() => qryController.text = item.title!);
  }

  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    var searchContainer = Container(
      decoration: const BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.all(Radius.circular(30)),
      ),
      child: TextFormField(
        autovalidateMode: AutovalidateMode.onUserInteraction,
        focusNode: focusNode,
        controller: qryController,
        decoration: InputDecoration(
          prefixIcon: const Icon(Icons.search),
          suffixIcon: InkWell(
            onTap: () => qryController.clear(),
            child: const Icon(Icons.clear, color: ThemeColors.primary),
          ),
          labelStyle: const TextStyle(fontSize: 18, color: ThemeColors.primary),
          isDense: true,
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(30),
            borderSide: const BorderSide(color: Colors.white),
          ),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(30),
            borderSide: const BorderSide(color: Colors.white),
          ),
        ),
        style: const TextStyle(fontSize: 18, color: ThemeColors.primary),
        textInputAction: TextInputAction.done,
        onChanged: (String query) => handleValueChanged(query),
      ),
    );

    var resultsContainer = Container(
      height: size.height / 2,
      margin: const EdgeInsets.only(top: 5),
      padding: const EdgeInsets.all(10),
      decoration: BoxDecoration(
        color: Colors.white,
        border: Border.all(color: Colors.grey),
        borderRadius: const BorderRadius.all(Radius.circular(Corners.s10)),
        boxShadow: const [
          BoxShadow(
            color: Colors.grey,
            spreadRadius: 1,
            blurRadius: 1,
            offset: Offset(0, 1),
          ),
        ],
      ),
      child: ListView.builder(
        itemExtent: 60,
        itemCount: filtered.length,
        itemBuilder: (ctx, index) {
          SearchItem item = filtered[index];
          return Padding(
            padding: const EdgeInsets.only(right: 5, bottom: 5),
            child: RawMaterialButton(
              fillColor: Colors.white.withOpacity(.75),
              focusElevation: 0,
              hoverColor: ThemeColors.accent,
              hoverElevation: 1,
              highlightElevation: 0,
              padding: const EdgeInsets.all(10),
              shape: const RoundedRectangleBorder(
                borderRadius: BorderRadius.all(Radius.circular(5)),
              ),
              onPressed: () => handleItemSelected(item),
              child: Align(
                alignment: Alignment.centerLeft,
                child: Text(
                  item.title!,
                  maxLines: 1,
                  style: TextStyles.Body1.size(20)
                      .bold
                      .textColor(ThemeColors.primary),
                ),
              ),
            ),
          );
        },
      ),
    );
    return SizedBox(
      child: Row(
        children: [
          SizedBox(width: widget.lMargin),
          Column(
            children: [
              const SizedBox(height: 30),
              searchContainer,
              if (hasFocus) resultsContainer,
            ],
          ).expanded(),
          SizedBox(width: widget.rMargin),
        ],
      ),
    );
  }
}
  1. Implementing Your Widget

Back to the other class where you were implementing it, you will need to add it as the top widget in your stack and it will take the full space so that it can float over other widgets as desired in the design.

Stack(
    children: <Widget>[
      HeaderWidget(
        showGoBackBtn: true,
        items: searchItems,
        onHelp: goToHelp,
        onSettings: goToSettings,
        onLogOut: goToStart,
      ).height(120).elevation(5),
      contentWidget.center().padding(top: 90),
      StudentSearch(
         items: searchItems,
         lMargin: 400,
         rMargin: 500,
      ),
    ],
),

Conclusion

In conclusion, the SearchWidget demonstrates how to create a search bar that displays a filtered list of items when it has focus. It makes use of various Flutter widgets such as TextFormField, ListView.builder, and RawMaterialButton, as well as stateful widgets such as StatefulWidget and State. The SearchWidget's state maintains a list of filtered items based on the user's input in the search bar. The focusNode and onFocusChange methods are used to detect whether the search bar has focus or not. When the search bar has focus, the filtered list is displayed in a resultsContainer widget. Overall, the widget provides a helpful example for implementing a search bar with filtered results in a Flutter app.


That is it for this one see you in the next one


Don't fail to Follow me here on Hashnode and On

Twitter @ JacksiroKe | Linked In Jack Siro | Github @ JacksiroKe

0
Subscribe to my newsletter

Read articles from Siro Daves directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Siro Daves
Siro Daves

Software engineer and a Technical Writer, Best at Flutter mobile app development, full stack development with Mern. Other areas are like Android, Kotlin, .Net and Qt