From 9a57acfccd02f0378ca9fe914bb563d5bad226d5 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 24 Apr 2025 23:26:55 +0200 Subject: [PATCH] optimize rebuilds by sampling input --- .../search/presentation/screens/search.dart | 11 ++- .../widgets/search_modules/feed_search.dart | 9 ++- .../search_modules/history_suggestions.dart | 9 ++- .../widgets/search_modules/tab_search.dart | 11 +-- .../presentation/widgets/container_chips.dart | 9 ++- .../hooks/sampled_value_notifier.dart | 59 +++++++++++++++ app/lib/utils/sampled_value_notifier.dart | 75 +++++++++++++++++++ 7 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 app/lib/presentation/hooks/sampled_value_notifier.dart create mode 100644 app/lib/utils/sampled_value_notifier.dart diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart index 4b70f234..1860dd03 100644 --- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -13,6 +13,7 @@ import 'package:lensai/features/geckoview/features/search/presentation/widgets/s import 'package:lensai/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart'; import 'package:lensai/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart'; import 'package:lensai/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart'; +import 'package:lensai/presentation/hooks/sampled_value_notifier.dart'; import 'package:lensai/utils/uri_parser.dart' as uri_parser; class SearchScreen extends HookConsumerWidget { @@ -27,6 +28,10 @@ class SearchScreen extends HookConsumerWidget { final searchTextController = useTextEditingController( text: initialSearchText, ); + final sampledSearchText = useSampledValueNotifier( + source: searchTextController, + sampleDuration: const Duration(milliseconds: 150), + ); final searchFocusNode = useFocusNode(); final defaultSearchBang = ref.watch( @@ -116,9 +121,9 @@ class SearchScreen extends HookConsumerWidget { submitSearch: submitSearch, ), const SliverToBoxAdapter(child: Divider()), - TabSearch(searchTextController: searchTextController), - FeedSearch(searchTextController: searchTextController), - HistorySuggestions(searchTextController: searchTextController), + TabSearch(searchTextListenable: sampledSearchText), + FeedSearch(searchTextNotifier: sampledSearchText), + HistorySuggestions(searchTextListenable: sampledSearchText), ], ), ), diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart index de5f1146..3c7a23a7 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -20,9 +21,9 @@ class FeedSearch extends HookConsumerWidget { static const _matchPrefix = '***'; static const _matchSuffix = '***'; - final TextEditingController searchTextController; + final ValueListenable searchTextNotifier; - const FeedSearch({required this.searchTextController}); + const FeedSearch({required this.searchTextNotifier}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -30,11 +31,11 @@ class FeedSearch extends HookConsumerWidget { final articlesAsync = ref.watch(articleSearchProvider(null)); - useListenableCallback(searchTextController, () async { + useListenableCallback(searchTextNotifier, () async { await ref .read(articleSearchProvider(null).notifier) .search( - searchTextController.text, + searchTextNotifier.value.text, // ignore: avoid_redundant_argument_values dont break things matchPrefix: _matchPrefix, // ignore: avoid_redundant_argument_values dont break things diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart index 62d6caee..ef4aa2fa 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; @@ -14,18 +15,18 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:sliver_tools/sliver_tools.dart'; class HistorySuggestions extends HookConsumerWidget { - final TextEditingController searchTextController; + final ValueListenable searchTextListenable; - const HistorySuggestions({super.key, required this.searchTextController}); + const HistorySuggestions({super.key, required this.searchTextListenable}); @override Widget build(BuildContext context, WidgetRef ref) { final historySuggestionsAsync = ref.watch(engineHistorySuggestionsProvider); - useListenableCallback(searchTextController, () async { + useListenableCallback(searchTextListenable, () async { await ref .watch(engineSuggestionsProvider.notifier) - .addQuery(searchTextController.text); + .addQuery(searchTextListenable.value.text); }); if (historySuggestionsAsync.hasValue && diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart index 2d9b3723..1ec69f7d 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; @@ -20,9 +21,9 @@ class TabSearch extends HookConsumerWidget { static const _matchPrefix = '***'; static const _matchSuffix = '***'; - final TextEditingController searchTextController; + final ValueListenable searchTextListenable; - const TabSearch({required this.searchTextController}); + const TabSearch({required this.searchTextListenable}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -46,11 +47,11 @@ class TabSearch extends HookConsumerWidget { ) .value; - useListenableCallback(searchTextController, () async { + useListenableCallback(searchTextListenable, () async { await ref .read(tabSearchRepositoryProvider(TabSearchPartition.search).notifier) .addQuery( - searchTextController.text, + searchTextListenable.value.text, // ignore: avoid_redundant_argument_values dont break things matchPrefix: _matchPrefix, // ignore: avoid_redundant_argument_values dont break things @@ -81,7 +82,7 @@ class TabSearch extends HookConsumerWidget { selectedContainer.value = null; }, containerFilter: (container) => (container.tabCount ?? 0) > 0, - searchTextController: searchTextController, + searchTextListenable: searchTextListenable, ), ], ), diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart index b0820ca7..de2f3f0f 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -20,22 +21,22 @@ class ContainerChips extends HookConsumerWidget { final void Function(ContainerDataWithCount)? onSelected; final void Function(ContainerDataWithCount)? onDeleted; - final TextEditingController? searchTextController; + final ValueListenable? searchTextListenable; const ContainerChips({ required this.selectedContainer, required this.onSelected, required this.onDeleted, this.containerFilter, - this.searchTextController, + this.searchTextListenable, this.displayMenu = true, }); @override Widget build(BuildContext context, WidgetRef ref) { final searchText = useListenableSelector( - searchTextController, - () => searchTextController?.text, + searchTextListenable, + () => searchTextListenable?.value.text, ); final containersAsync = ref.watch( diff --git a/app/lib/presentation/hooks/sampled_value_notifier.dart b/app/lib/presentation/hooks/sampled_value_notifier.dart new file mode 100644 index 00000000..a3545370 --- /dev/null +++ b/app/lib/presentation/hooks/sampled_value_notifier.dart @@ -0,0 +1,59 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lensai/utils/sampled_value_notifier.dart'; + +/// A custom hook that creates a sampled ValueNotifier from another ValueNotifier +ValueNotifier useSampledValueNotifier({ + required ValueNotifier source, + required Duration sampleDuration, +}) { + return use( + _SampledValueNotifierHook( + source: source, + sampleDuration: sampleDuration, + ), + ); +} + +/// Hook implementation for sampled ValueNotifier +class _SampledValueNotifierHook extends Hook> { + final ValueNotifier source; + final Duration sampleDuration; + + const _SampledValueNotifierHook({ + required this.source, + required this.sampleDuration, + }); + + @override + _SampledValueNotifierHookState createState() => + _SampledValueNotifierHookState(); +} + +class _SampledValueNotifierHookState + extends HookState, _SampledValueNotifierHook> { + late SampledValueNotifier _sampledNotifier; + + @override + void initHook() { + super.initHook(); + _sampledNotifier = SampledValueNotifier( + source: hook.source, + sampleDuration: hook.sampleDuration, + ); + } + + @override + ValueNotifier build(BuildContext context) { + return _sampledNotifier; + } + + @override + void dispose() { + _sampledNotifier.dispose(); + super.dispose(); + } + + @override + String get debugLabel => 'useSampledValueNotifier<$T>'; +} diff --git a/app/lib/utils/sampled_value_notifier.dart b/app/lib/utils/sampled_value_notifier.dart new file mode 100644 index 00000000..d909e466 --- /dev/null +++ b/app/lib/utils/sampled_value_notifier.dart @@ -0,0 +1,75 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; + +/// A ValueNotifier that acts as a proxy for another ValueNotifier, +/// sampling its values based on a given duration. +class SampledValueNotifier extends ValueNotifier { + /// The source ValueNotifier to sample from + final ValueNotifier _source; + + /// The duration to sample at + final Duration _sampleDuration; + + /// Timer for sampling + Timer? _timer; + + /// Whether a value has changed since the last sample + bool _hasNewValue = false; + + /// Subscription to the source ValueNotifier + late final VoidCallback _sourceListener; + + /// Creates a SampledValueNotifier that samples values from [source] + /// at the specified [sampleDuration]. + SampledValueNotifier({ + required ValueNotifier source, + required Duration sampleDuration, + }) : _source = source, + _sampleDuration = sampleDuration, + super(source.value) { + // Set up listener for source changes + _sourceListener = () { + _hasNewValue = true; + + // Start timer if not already running + if (_timer == null || !_timer!.isActive) { + _startTimer(); + } + }; + + _source.addListener(_sourceListener); + _startTimer(); + } + + /// Starts the sampling timer + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(_sampleDuration, _onSampleTime); + } + + /// Called when it's time to sample + void _onSampleTime(Timer timer) { + if (_hasNewValue) { + value = _source.value; + _hasNewValue = false; + } + } + + @override + void dispose() { + _timer?.cancel(); + _source.removeListener(_sourceListener); + + super.dispose(); + } +} + +extension ValueNotifierSampleExtension on ValueNotifier { + /// Creates a new ValueNotifier that samples this ValueNotifier's values + /// at the specified [duration]. + /// + /// The returned ValueNotifier must be disposed when no longer needed. + ValueNotifier sampleTime(Duration duration) { + return SampledValueNotifier(source: this, sampleDuration: duration); + } +}