Full source code: github.com/seenickcode/fluttercrashcourse-lessons
Video: YouTube
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.
Intermediate.
TextFormField
and GridView
google_fonts
flutter_supabase
.onChanged
handler to update search results.Stay tuned for:
Stay tuned for new posts via:
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 file
screens/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!
);
}
}
Baby steps. Let's run this app to ensure things are working properly!
GridView
with Some Hardcoded DataWe'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.
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 = '';
}
?
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.
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:
ThemeData
and the google_fonts
PackageLet'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
);
}
TextFormField
as Our Search FieldNow, 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.
Tile
WidgetWe 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:
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.
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.
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!
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:
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:
Creating a table.
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!)
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.
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:
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.
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.
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!
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!
supabase_flutter
In your terminal, run:
flutter pub add supabase_flutter
WARNING: ensure you use the supabase_flutter
package, not the supabase
package!
main.dart
Before we integrate, you'll need:
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.
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.
_onSearchFieldChanged
supabase_flutter
Add this to your search.dart
file:
// search.dart
import 'package:supabase_flutter/supabase_flutter.dart';
// ...
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 let's implement a function that executes when a user types in the search field. For our search.dart
screen, it will:
Update our state's _input
field.
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 ''".
Search our backend, return the results.
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;
}
});
}
_searchUsers
Now, we need to implement our search function which queries our Supabase backend.
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.
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.
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.
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.
Ensure there are no compilation errors, then stop and re-run your app.
You should see this when you search for "fa", for example:
If you have any issues with this exact code, feel free to open an Issue here.
I hope this intermediate level tutorial was informative! Keep in touch!
Stay tuned for new posts via:
Happy Fluttering, Nick