Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Created June 26, 2024 19:33
Show Gist options
  • Save slightfoot/6e04b070478b7be3e1463db68517c277 to your computer and use it in GitHub Desktop.
Save slightfoot/6e04b070478b7be3e1463db68517c277 to your computer and use it in GitHub Desktop.
Search Box Overlays - by Simon Lightfoot - Humpday Q&A :: 26th June 2024 #Flutter #Dart https://www.youtube.com/watch?v=X0bin1qozYg
// MIT License
//
// Copyright (c) 2024 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(const App());
}
class SearchSuggestion {
const SearchSuggestion({required this.displayLabel});
final String displayLabel;
}
class SuggestionsProvider extends ValueNotifier<List<SearchSuggestion>> {
SuggestionsProvider() : super([]);
Timer? _debounceQueryTimer;
int _resultToken = 0;
void findSuggestions(String query) {
_debounceQueryTimer?.cancel();
_debounceQueryTimer = Timer(
const Duration(milliseconds: 500),
() async {
final localToken = ++_resultToken;
final results = await _performQuery(query);
if (_resultToken == localToken) {
value = results;
}
},
);
}
Future<List<SearchSuggestion>> _performQuery(String query) async {
// Mock a call to a server to get some response data
await Future.delayed(const Duration(milliseconds: 1500));
return List.generate(10, (int index) {
return SearchSuggestion(displayLabel: '$query $index');
});
}
void clearSuggestions() {
_resultToken++;
value = [];
}
}
class App extends StatefulWidget {
const App({super.key});
static SuggestionsProvider suggestionsProviderOf(BuildContext context) {
return context.findAncestorStateOfType<_AppState>()!._suggestionsProvider;
}
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
late final SuggestionsProvider _suggestionsProvider;
@override
void initState() {
super.initState();
_suggestionsProvider = SuggestionsProvider();
}
@override
void dispose() {
_suggestionsProvider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: Home(),
);
}
}
class Home extends StatefulWidget {
const Home({super.key});
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
SearchSuggestion? _selected;
void _onSearchResultSelected(SearchSuggestion result) {
setState(() => _selected = result);
}
@override
Widget build(BuildContext context) {
// Overlay widget is used to capture the search box suggestions drop-down
// to within the bounds of the "page".
return Overlay(
initialEntries: [
OverlayEntry(
builder: (BuildContext context) {
return Material(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AppSearchBar(
onSearchResultSelected: _onSearchResultSelected,
),
Expanded(
child: MediaQuery.removeViewPadding(
context: context,
removeTop: true,
child: _selected != null
? ListView.builder(
itemBuilder: (BuildContext context, int index) {
return ListTile(
title:
Text('${_selected!.displayLabel} $index'),
);
},
)
: const Center(
child: Text('Empty'),
),
),
),
],
),
);
},
),
],
);
}
}
class AppSearchBar extends StatefulWidget {
const AppSearchBar({
super.key,
required this.onSearchResultSelected,
});
final ValueChanged<SearchSuggestion> onSearchResultSelected;
@override
State<AppSearchBar> createState() => _AppSearchBarState();
}
class _AppSearchBarState extends State<AppSearchBar> {
late final FocusNode _queryFocus;
late final TextEditingController _queryController;
final _suggestionsLayerLink = LayerLink();
OverlayEntry? _suggestionsOverlayEntry;
LocalHistoryEntry? _searchHistoryEntry;
bool _searchOpen = false;
@override
void initState() {
super.initState();
_queryFocus = FocusNode(debugLabel: 'Search Bar Query Box');
_queryFocus.addListener(_onQueryChanged);
_queryController = TextEditingController();
_queryController.addListener(_onQueryChanged);
}
void _openSearch() {
setState(() => _searchOpen = true);
_queryFocus.requestFocus();
_searchHistoryEntry = LocalHistoryEntry(
onRemove: () {
_suggestionsOverlayEntry?.remove();
_suggestionsOverlayEntry = null;
setState(() => _searchOpen = false);
},
);
ModalRoute.of(context)!.addLocalHistoryEntry(_searchHistoryEntry!);
}
void _clearSearch() {
_queryController.text = '';
}
void _closeSearch() {
ModalRoute.of(context)!.removeLocalHistoryEntry(_searchHistoryEntry!);
_searchHistoryEntry!.remove();
_searchHistoryEntry = null;
}
void _onQueryChanged() {
if (!_queryFocus.hasFocus) {
return;
}
final queryText = _queryController.text.trim();
if (queryText.isEmpty) {
App.suggestionsProviderOf(context).clearSuggestions();
return;
}
if (_suggestionsOverlayEntry == null) {
App.suggestionsProviderOf(context).clearSuggestions();
_suggestionsOverlayEntry = OverlayEntry(
builder: (BuildContext context) {
return _SearchBoxSuggestions(
onSuggestionPressed: (SearchSuggestion value) {
_closeSearch();
widget.onSearchResultSelected(value);
},
link: _suggestionsLayerLink,
);
},
);
Overlay.of(context).insert(_suggestionsOverlayEntry!);
}
App.suggestionsProviderOf(context).findSuggestions(queryText);
}
@override
void dispose() {
_suggestionsOverlayEntry?.remove();
_queryFocus.dispose();
_queryController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.grey.shade300,
child: CompositedTransformTarget(
link: _suggestionsLayerLink,
child: SafeArea(
bottom: false,
child: AnimatedSwitcher(
duration: kThemeChangeDuration,
child: _searchOpen
? Padding(
key: const ValueKey('search-box-open'),
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
BackButton(
onPressed: _closeSearch,
),
Expanded(
child: TextFormField(
controller: _queryController,
focusNode: _queryFocus,
// onTapOutside: (_) => _closeSearch(),
),
),
IconButton(
onPressed: _clearSearch,
icon: const Icon(Icons.cancel),
),
],
),
)
: Padding(
key: const ValueKey('search-box-closed'),
padding: const EdgeInsets.only(
left: 16.0,
top: 8.0,
bottom: 8.0,
),
child: Row(
children: [
Expanded(
child: Text(
'App Title',
style: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
onPressed: _openSearch,
icon: const Icon(Icons.search),
),
],
),
),
),
),
),
);
}
}
class _SearchBoxSuggestions extends StatelessWidget {
const _SearchBoxSuggestions({
required this.link,
required this.onSuggestionPressed,
});
final LayerLink link;
final ValueChanged<SearchSuggestion> onSuggestionPressed;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topLeft,
child: CompositedTransformFollower(
link: link,
targetAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topLeft,
offset: const Offset(0.0, -10.0),
child: SizedBox(
width: double.infinity,
child: IntrinsicHeight(
child: TextFieldTapRegion(
child: Material(
color: Colors.grey.shade100,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12.0),
bottomRight: Radius.circular(12.0),
),
elevation: 8.0,
child: ListTileTheme.merge(
dense: true,
child: ValueListenableBuilder(
valueListenable: App.suggestionsProviderOf(context),
builder: (BuildContext context,
List<SearchSuggestion> suggestions, Widget? child) {
return Column(
children: [
for (final suggestion in suggestions) //
ListTile(
onTap: () => onSuggestionPressed(suggestion),
title: Text(suggestion.displayLabel),
),
],
);
},
),
),
),
),
),
),
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment