Walkthrough: Create a Flutter Search Screen

Full source code: github.com/seenickcode/fluttercrashcourse-lessons

Video: YouTube

Overview

What We'll Build

A single search screen in Flutter with a search field at the top and a list of search results below as beautiful, randomized color gradient grid items. As the user types, we kick off a backend search against a real database, returning the results to the user.

Required Skill Level

Intermediate.

Things We'll Learn in This Tutorial

  1. Flutter Search using TextFormField and GridView
  2. Rendering randomized color gradients in Flutter
  3. Flutter Theming with google_fonts
  4. Generating high volume test data fixtures as a CSV file
  5. SQL Schema Basics
  6. Postgres full text search concepts and features
  7. Using Supabase to set up a database, import our data
  8. Using the Flutter Supabase package to wire up our screen to our backend

How We'll Build This

Frontend

  1. Create a Flutter project showing a simple search screen.
  2. Hardcode some test data, theme our app and show some test search results.

Backend

  1. Generate a CSV file of test fixtures.
  2. Create a Supabase.io project and schema. Discuss Supabase.io (and why we aren't using Firebase or our own custom API)
  3. Download a Postgres client and import our CSV file.
  4. Try out some test queries.

Integration

  1. Integrate flutter_supabase.
  2. Wire up our text field's onChanged handler to update search results.
  3. Demo finalized product and touch on potential follow-up tutorials.

Demo

Demo

Follow up Tutorials

Stay tuned for:

Stay tuned for new posts via:

Part I: Frontend Development

1. Creating Our Project, Screen and Routes

a. Ensure we're using the latest version of Flutter by running flutter upgrade.

b. Create a new project using flutter create super_search.

c. Let's clean up our project now so we have a simple starting point.

Create the following files:

main.dart:

// main.dart
import 'package:flutter/material.dart';
import 'app.dart';

void main() {
    runApp(const App());
}

app.dart:

import 'package:flutter/material.dart';
import 'screens/search/search.dart';
import 'style.dart';

class App extends StatelessWidget {
  const App({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Super Search',
      home: const Search(),
      theme: appTheme,
    );
  }
}

style.dart:

import 'package:flutter/material.dart';

// we'll fill this all in next!
final appTheme = ThemeData();

Create a screens directory under our lib directory. Create a search directory under this new screens directoryand create our first screen filescreens/search/search.dart`.

Notice our file naming convention. Each screen will have a .dart file of the same name denoting that it will be the primary file for this directory. In the next steps, we will add more files to this search directory, containing additional widgets used in this screen.

This screen will show a blank page. What we're doing is simply ensuring that our app runs and that we can wire this screen up as our home screen, as defined in app.dart's MaterialApp widget definition.

screens/search/search.dart:

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

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

  @override
  State<Search> createState() => _SearchState();
}

class _SearchState extends State<Search> {
  // to fill out next!

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text('Search Users'),
        ),
        body: Container() // to fill out next!
    );
  }
}

Does This Run?

Baby steps. Let's run this app to ensure things are working properly!

2. Add GridView with Some Hardcoded Data

We'll now show a list of search results in the form of a grid of items. Each search result will represent a "user" that we are searching for. In the next steps, we will generate this data as a list of user names.

To make things look attractive, we will show the user's name as well as a colorful grid item tile background. The color gradient we'll show will be randomized! The approach reflects a realistic app, where if the user hasn't uploaded a profile photo or avatar, a default randomized, attractive asset is generated so we're not just showing something boring, like the user's name alone.

Data Modeling

Let's think about what data will drive this screen. In other word, we should ask ourselves, what is the "data model" or "information architecture" for this app?

Well, the data model is simple. We'll have a "user" entity. We're going to want to show user names which represent users. Now, because this is a super simple example app, we won't even create a separate class that represents a user. We are just showing a list of user names in our search results, so a List<String> type will suffice.

Let's add some hardcoded mock data to our search screen. We'll remove this hardcoded data in a later step in this tutorial so it's a proper "full stack" example, powered by a backend.

Add the following as a member of _SearchState in screens/search/search.dart:

// search.dart
// ...

class _SearchState extends State<Search> {
    List<String>? _results = ['Adam', 'Addi', 'Adrey'];
    String _input = '';
}

What's With the ? and the _ Mean Here?

So, the ? above in List<String>? simply means that this value can have a null value assigned to it. This is what "null safety" in Dart essentially means.

The _ in _results means this member is private to the class it's in, meaning that nothing can access it outside the class.

Why do we want this _results nullable? Because our search results section of the screen will have three states:

a. User starts the app, nothing is shown in the search results. (_results is null).

b. User starts searching for a user that doesn't exist, (_results is now [], in other words, an empty list).

c. User searches for something and some results are returned (_results is now populated with a list of items, i.e. if they search for "ad" they may see ['Adam', 'Addi', 'Adrey'])

Later in the tutorial, we will populate this with some backend data.

Lastly, the _input field here, we we'll see in a moment, will be used to keep track of the user's input.

Implementing Our GridView

Add the following to show a grid view that is populated by our data.

screens/search/search.dart:

// search.dart
// ...

@override
Widget build(BuildContext context) {
return Scaffold(
    appBar: AppBar(
        title: const Text('Search Users'),
    ),
    body: Column(children: [
        Padding(
            padding: const EdgeInsets.symmetric(horizontal: 10),
            child: Container() // we'll add our form field here later!
            ),
        Expanded(
            child: (_results ?? []).isNotEmpty
                ? GridView.count(
                    childAspectRatio: 1,
                    crossAxisCount: 2,
                    padding: const EdgeInsets.all(2.0),
                    mainAxisSpacing: 1.0,
                    crossAxisSpacing: 1.0,
                    children: _results!.map((r) => Text(r)).toList())
                : Padding(
                    padding: const EdgeInsets.only(top: 200),
                    child: _results == null
                        ? Container()
                        : Text("No results for '$_input'",
                            style: Theme.of(context).textTheme.caption))),
    ]));
}

Let's break this down! I'll explain the important parts above as this is meant to be an intermediate tutorial.

First, for layout, we have a Column widget, since we will be rendering our widgets from top to bottom: a) text field for searching (we haven't added it yet) and b) a GridView.

The latter is wrapped in an Expanded widget so it can take up as much space as possible, filling the rest of the screen vertically.

Second, we have some conditional rendering here, either showing a GridView or a Padding widget containing our "No results" message. The (_results ?? []) is shorthand for "if _results isn't null, use it, or else, just use [] in its place".

Now, you may ask, why aren't just checking to see if _results isn't null or []? Well, remember our reasoning from the previous section!

You'll also see this ? after the expression. This is called a ternary operator, and this is its syntax (some condition) ? (if condition is true execute this) : (or else, execute this), which is shorthand for:

if (some condition) {
    // if condition is true execute this
} else {
    // or else, execute this
}

This lets us, very concisely, conditionally show a GridView if (_results ?? []).isNotEmpty is true or else a Padding widget, if it is false.

Third, we use GridView.count because as our search results get updated, we'll have a fixed amount of results to show. When we query our backend later in this tutorial, we'll be fetching a fixed set of results.

Alternatively, if we wanted an "infinitely" scrolling GridView, we could use GridView.builder(). For search experiences though, it's best to limit the results and encourage the user to narrow down their search if it's too broad.

For now, for each search result, we'll map() it to a Text widget.

Fourth, and finally, we conditionally show a Padding widget with a "No results" message. It is also conditionally rendered using another ternary operator.

Note:

3. Add Our ThemeData and the google_fonts Package

Let's add our search field with some additional style!

First, install the Google Fonts package by running this in your terminal:

flutter pub add google_fonts

Next, add this snippet to our style.dart file. It contains our final style for this tutorial. This includes a custom font called "outfit" and, as you will soon see, the ability to access various "themes" when we want to style things like our app bar, various types of text, etc.

// style.dart

final TextStyle placeholderTextFieldStyle =
    TextStyle(color: Colors.grey.shade400);

final appTheme = ThemeData(
  appBarTheme: AppBarTheme(
      color: Colors.grey.shade900,
      elevation: 0,
      titleTextStyle: TextStyle(
          fontSize: 26.0, fontFamily: GoogleFonts.rubik().fontFamily)),
  brightness: Brightness.light,
  primaryColor: Colors.grey.shade900,
  fontFamily: GoogleFonts.outfit().fontFamily,
  // fontFamily: 'Open sans',
  textTheme: TextTheme(
      headline1: const TextStyle(fontSize: 36.0),
      headline2: const TextStyle(
          fontSize: 22.0, fontWeight: FontWeight.bold, color: Colors.white),
      bodyText1: const TextStyle(
          fontSize: 26.0, fontWeight: FontWeight.bold, letterSpacing: 1.5),
      caption: TextStyle(
          fontSize: 18.0,
          fontWeight: FontWeight.bold,
          color: Colors.grey.shade400)),
);

Now, update app.dart to use this ThemeData:

// app.dart

@override
Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Super Search',
        home: const Search(),
        theme: appTheme, // <-- add this
    );
}

4. Adding a TextFormField as Our Search Field

Now, we can add our TextFormField to our screens/search/search.dart screen which will use the style we've added above:

// search.dart
import 'package:super_search/style.dart'; // <-- add this

  // ...

  // Scaffold(
  //   ...
  //   body: Column(children: [
  //     Padding(
  //       padding: ...
  //       child:
            TextFormField(
                style: Theme.of(context).textTheme.bodyText1,
                onChanged: _onSearchFieldChanged,
                autocorrect: false,
                autofocus: true,
                decoration: InputDecoration(
                    hintText: "Name",
                    hintStyle: placeholderTextFieldStyle,
                    border: InputBorder.none,
                    focusedBorder: InputBorder.none,
                    enabledBorder: InputBorder.none,
                    errorBorder: InputBorder.none,
                    disabledBorder: InputBorder.none,
                )
            )
  // ...
  // ... our build() function ends here

  // now add this function to our _SearchState class...

  _onSearchFieldChanged(String value) async {
      // to fill out next!
  }

Note: if you're having a hard time adding the snippet above to our existing code, take a peek at the final code solution here.

First, we style our field now using one of our text themes we've defined in style.dart via Theme.of(context).textTheme. The bodyText1 here represents a primary style of text (i.e. in things like paragraphs or blocks of text, form fields or field labels in the app).

Second, we have some basic text decoration here to customize the field. We'll be removing all this border style used in the classic "Material" style form fields. The hintStyle here is custom and represents the placeholder text when nothing is filled out in the field.

Lastly, we have the onChanged parameter, pointing to our _onSearchFieldChanged handler. This logic will be filled out next. It will get triggered every time a user types in this form field. Keep reading to see the implementation.

5. Adding Our Custom Tile Widget

We will now create a custom widget that represents a single search result.

Create screens/search/tile.dart and add this:

// tile.dart

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

class Tile extends StatelessWidget {
  final String title;

  const Tile(this.title, {Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Stack(children: [
      Container(
          decoration: BoxDecoration(
              gradient: LinearGradient(
        begin: Alignment.topRight,
        end: Alignment.bottomLeft,
        colors: [
          Colors.primaries[Random().nextInt(Colors.primaries.length)],
          Colors.primaries[Random().nextInt(Colors.primaries.length)],
        ],
      ))),
      Padding(
          padding: const EdgeInsets.all(10),
          child: Column(
              mainAxisAlignment: MainAxisAlignment.end,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Text(
                  title.toUpperCase(),
                  style: Theme.of(context).textTheme.headline2,
                  textAlign: TextAlign.end,
                )
              ])),
    ]);
  }
}

Then, update search.dart to use this tile.

// search.dart
import 'package:super_search/screens/search/tile.dart'; // <-- add this

// ...

// now, replace `GridView`'s `children` parameter with this,
// as we're now using `Tile` instead of `Text`
//
// GridView(
// ...

    children: _results!.map((r) => Tile(r)).toList())

// ...

Let's walk through tile.dart briefly.

First, Tile allows us to customize the title of the tile. In this case, we'll use it for the user's name in a given search result.

Second, we render a Container with a randomized color gradient. I won't go into the logic of how we render random colors here as it's beyond the scope of this tutorial, but feel free to read up on the documentation for anything you find interesting!

// tile.dart
// ...

LinearGradient( // ...
  colors: [] // <-- here we use 2 colors for our gradient. cool!
)

// ...

Lastly, we render a Text widget, styling it as all upper case, using, again, a text theme we've defined in style.dart, this time, as a headline2, because really, our app bar's title, "Search Users" represents more of the "primary" headline.

// tile.dart
// ...

// here, we use more of our text theme to style the user's name
Theme.of(context).textTheme.headline2

That's it for the frontend more or less. We still have to wire things up to query the backend but we have a working UI with some example data.

Try to run the app again to ensure things are working as expected.

Because we added the google_fonts package and our theme data to main.dart, if you have your simulator/emulator/device running, stop the app and start it again to see your changes.

You should now see this when you run the app:

Example screen showing app bar at the top and blank screen below

Part II: Backend Development

Now, let's set up our database, API and populate it with 100,000 generated example users. You'll see how easy this is. We're not implementing a custom API but rather, based on our database schema, an API will be generated for us using Supabase.

1. Download Our Sample .csv file

Click the "download" button here to grab our .csv file containing 100,000 auto generated users. We used an online CSV generator to generate it if you're curious.

2. Create a Free Supabase.io Project

First, go to supabase.io, register and create a new organization and project. I've named my organization "Test" and my project "super_search" but you can name them to whatever you'd like.

WARNING: be sure to note down the database password you'll use when creating the project. You won't be able to retrieve it if it is lost!

3. Import Our Database Schema

Now, since Supabase uses the very popular, industry standard PostgreSQL (or "Postgres" for short) database, we'll need to create the following before we start importing data:

  1. A table containing a list of user names.
  2. Add a column (and index) that we can search against using Postgres' Full Text Search feature.

First, download the SQL script here. Then, sign into your Supabase account, navigate to the Supabase project you've created, and click the "SQL Editor" icon on the left hand side. Create a "New Query", paste the followin in and run it to apply the schema:

-- supabase_migrations.sql

-- create our table, containing a list of names (effectively "users")
CREATE TABLE names (
  id serial primary key,
  fname text,
  lname text
);

-- add an additional column called 'fts' (full text search)
-- which will contain the values "<first name> <last name>", allowing
-- us to query against it using a special full text search query
-- you'll see in the next step
-- BONUS READ: https://supabase.com/docs/guides/database/full-text-search
ALTER TABLE
  names
ADD COLUMN
  fts tsvector generated always as (to_tsvector('english', fname || ' ' || lname)) stored;

-- create an index so we can quickly query by our new column, 'fts'
CREATE INDEX name_fts ON names USING gin (fts);

Basically here we are:

  1. Creating a table.

  2. Creating a 'fts' column (full text search) containing the values we want to search against. Now this isn't "needed", because we can always use a standard SQL wildcard query, but it will be quite slow with 100,000 records to search against, even if we use a database index! (mine clocked in at around 250ms! slow!)

  3. Creating a standard database index for fast lookups.

You shouldn't feel obligated to understand everything going on in the snippet above but if you get the gist of it, since this is an intermediate tutorial, that's ideal!

With this all in place, using a very basic Supabase database they provide, with 100,000k record, if we run some sample "Full Text Search" query WHERE fts @@ to_tsquery('nick:*'), which uses a special syntax, we can query at around 25-50ms vs 250ms using the standard Postgres method of a simple ... WHERE fname = 'nick%'. Cool!

If you want to read up Postgres' Full Text Search feature, this is a great article.

In the next steps, you'll see how we can use this new 'fts' column to search against using a wildcard.

NOTE: for very high traffic or larger applications, a separate type of database, such as Elastic Search or a service such as Algolia is typically used vs searching against a traditional relational database. But, for smaller apps or something with only a few thousand users, setting up a full text search like this will work perflectly and keep your backend simple.

4. Setting Up Our SQL Client

Download

Now, we can indeed import our .csv data using Supabase's UI. But unfortunately, at present, this only works when creating the table via the UI and it doesn't let us re-import things later. Currently, the UI doesn't allow us to create the advanced schema we want such as this full text search column 'fts'.

Therefore, it's best to simply download a Postgres client which will allow us to:

  1. Take a peek at our actual database (and data).
  2. Import our CSV file!
  3. Run some test queries.

NOTE: we can perform queries via the Supabase UI as well.

On macOS, the client I personally like is Postico (free trial availble).

For Linux, Windows or macOS, I also like TablePlus which is free.

Configure

Run whatever app you've downloaded above, create a connection using the connection settings you'll find in your Supabase account. Go to your Supabase project > Settings > Database and find the Connection Info section.

Example connection screen

5. Import Our Data

Now, most SQL clients will simply allow users to import a .csv file. This is the easist way to quickly inject our test data.

If you're using TablePlus (or Postico!), ensure you connect to your database and you can see the names table we've set up. Right click it and select "Import..." > "From CSV...", select your file.

Next, you'll see an "Import CSV Wizard". Make sure you chose the columns you want to import, which will be "firstname" and "lastname" (as cited on line 0 in our .csv file). Choose "fname" and "lname" respectively in the dropdown for each column (they will be "Do not import" by default). Now click "Import".

Finally, ensure that the data is there! You can either double click the table and make sure the data exist. Or, if you want to play around with some queries, open a new query window in your client (or via Supabase's UI) and run the examples we have here.

Make sure you have data imported into your names table!

Part III: Integration!

Let's now wire up our Flutter app with our shiny new backend. Because we're using Supabase, a RESTful API is automatically generated for us. Even better, we won't need to worry about JSON or serialization/deserialization because Supabase offers a package, supabase_flutte which allows us to easily query this API!

1. Add supabase_flutter

In your terminal, run:

flutter pub add supabase_flutter

WARNING: ensure you use the supabase_flutter package, not the supabase package!

2. Update main.dart

Grab Your Configuration Values

Before we integrate, you'll need:

  1. Your Supabase project URL.
  2. Your Supabase anon/public key.

Go to your Supabase project > Settings > API. Your project URL will be located in the Config section. (Note: this isn't quite a "secret", so don't worry too much about keeping it super secret)

Next, note down your "anon/public key". This is a very long ID that tells Supabase who is using their API. This is public, and also doesn't need to be super secret because our table's data is publically available (i.e. no user authentication will be needed).

This anon/public key is located on the same page, in the Project API Keys section's "anon, public" field.

Add the Integration Code

Update `main.dart with the following, replacing the values in the code with what you have grabbed in the last step:

// main.dart

import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; // <- don't forget this!
import 'app.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Supabase.initialize(
      url: 'YOUR SUPABASE PROJECT URL',
      anonKey: 'YOUR SUPABASE ANON/PUBLIC KEY',
      debug: true);

  runApp(const App());
}

All set. You can optionally stop and run the app again if you want to ensure nothing crashes at startup.

3. Implementing _onSearchFieldChanged

Import supabase_flutter

Add this to your search.dart file:

// search.dart
import 'package:supabase_flutter/supabase_flutter.dart';
// ...

Remove Our Hardcoded Data

Convert our List<String>? _results = ['Adam', 'Addi', 'Adrey']; to:

// search.dart
// ...

List<String>? _results;

Ensuring that we no longer have any hardcoded values here.

Now Implement

Now let's implement a function that executes when a user types in the search field. For our search.dart screen, it will:

  1. Update our state's _input field.

  2. If the user's input is blank (a cleared field), we set our _results back to null, so we can avoid showing the awkward message "No results for ''".

  3. Search our backend, return the results.

  4. Update our state's _results list. If there are no results, we assign it null, again, allowing us to hide our "No results" message.

So add the following to search.dart's _SearchState class:

// search.dart
// ...

_onSearchFieldChanged(String value) async {
    _input = value;

    if (value.isEmpty) {
        setState(() => _results = null);
    }

    final results = await _searchUsers(value);

    setState(() {
        _results = results;
        if (value.isEmpty) {
            _results = null;
        }
    });
}

4. Implementing _searchUsers

Now, we need to implement our search function which queries our Supabase backend.

A Note About "Architecture"

NOTE: normally, for a larger app, we'd use the repository pattern and maybe even the Provider package to separate out our business logic from our search.dart file, which should only deal with UI. For simple apps, this is overkill and adding a simple query like this (it's only going to be 15 lines) of code, is fine, especially since this query isn't use anywhere else in the app. The takeaway here is, don't get obsessed with a complex "architecture" pattern if your code is simple and understandable.

With that said, once we start (maybe in a later tutorial) wanting to add integration tests, more backend calls, more reactivity across screens based on our state, we'll want to start leveraging what I mentioned, which as repositories, Provider, etc.

Implement

Anyway...

So now, add the following to search.dart's _SearchState class:

// search.dart
// ...

Future<List<String>> _searchUsers(String name) async {
    final result = await Supabase.instance.client
        .from('names')
        .select('fname, lname')
        .textSearch('fts', "$name:*")
        .limit(100)
        .execute();

    if (result.error != null) {
      print('error: ${result.error.toString()}');
      return [];
    }

    final List<String> names = [];
    for (var v in ((result.data ?? []) as List<dynamic>)) {
      names.add("${v['fname']} ${v['lname']}");
    }
    return names;
  }

TIP: if you want a detailed explanation of the code, I added a ton of comments. Just take a look at the example source code here.

Here, we leverage Supabase's (Postgres') full text search feature using a "wildcard" and whatever the user entered.

A Note About Error Handling

Regarding error handling, we aren't doing "proper" error handling as this is just an example. Typically we'd handle any exceptions via the callee of this function.

One way of implementing more built out error handling here is that we could throw an error and the callee can catch it and thus have control over how to handle the error. We could log it to an external logging service such as Rollbar and show a nice looking dialog message to the user.

Converting Results to a List of Names

Lastly, we convert the data that is returned to a list of names. result.data is a list of Maps, where each map represents a returned row in our database. Each key of the map represents a table column.

5. Try It Out!

Ensure there are no compilation errors, then stop and re-run your app.

You should see this when you search for "fa", for example:

Final search example

If you have any issues with this exact code, feel free to open an Issue here.

That's It

I hope this intermediate level tutorial was informative! Keep in touch!

Stay tuned for new posts via:

Happy Fluttering, Nick