Skip to content

Commit d4226b8

Browse files
committed
feat(feed): auto-scroll to active filter chip in SavedFiltersBar
Implements auto-scrolling functionality in the `SavedFiltersBar`. When a filter chip is selected (including the creation of a "Custom" filter or a new saved filter), the horizontal list now automatically scrolls to ensure the active chip is visible on screen. This is achieved by: - Using a `BlocListener` to detect changes to `activeFilterId`. - Associating a `GlobalKey` with each filter chip. - Calling `Scrollable.ensureVisible()` within a post-frame callback to trigger a smooth scroll animation to the active chip's context.
1 parent 7f142f9 commit d4226b8

File tree

1 file changed

+195
-138
lines changed

1 file changed

+195
-138
lines changed

lib/headlines-feed/widgets/saved_filters_bar.dart

Lines changed: 195 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class SavedFiltersBar extends StatefulWidget {
2828

2929
class _SavedFiltersBarState extends State<SavedFiltersBar> {
3030
final _scrollController = ScrollController();
31+
// A map to hold GlobalKeys for each filter chip. This allows us to find
32+
// the chip's context and scroll to it programmatically.
33+
final Map<String, GlobalKey> _chipKeys = {};
3134

3235
@override
3336
void initState() {
@@ -46,171 +49,225 @@ class _SavedFiltersBarState extends State<SavedFiltersBar> {
4649
final l10n = AppLocalizationsX(context).l10n;
4750
final theme = Theme.of(context);
4851

49-
return SizedBox(
50-
height: 52,
51-
child: BlocBuilder<HeadlinesFeedBloc, HeadlinesFeedState>(
52-
builder: (context, state) {
53-
final savedFilters = state.savedFilters;
54-
final userPreferences = context
55-
.watch<AppBloc>()
56-
.state
57-
.userContentPreferences;
58-
59-
// Determine if the user is following any content to decide whether
60-
// to show the "Followed" filter chip.
61-
final isFollowingItems =
62-
(userPreferences?.followedTopics.isNotEmpty ?? false) ||
63-
(userPreferences?.followedSources.isNotEmpty ?? false) ||
64-
(userPreferences?.followedCountries.isNotEmpty ?? false);
65-
66-
final activeFilterId = state.activeFilterId;
67-
final listView = ListView(
68-
controller: _scrollController,
69-
scrollDirection: Axis.horizontal,
70-
padding: EdgeInsets.zero,
71-
children: [
72-
IconButton(
73-
icon: const Icon(Icons.filter_list),
74-
tooltip: l10n.savedFiltersBarOpenTooltip,
75-
onPressed: () => context.goNamed(Routes.feedFilterName),
76-
),
77-
const VerticalDivider(
78-
width: AppSpacing.md,
79-
indent: AppSpacing.md,
80-
endIndent: AppSpacing.md,
81-
),
82-
Padding(
83-
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
84-
child: ChoiceChip(
85-
label: Text(l10n.savedFiltersBarAllLabel),
86-
labelPadding: const EdgeInsets.symmetric(
87-
horizontal: AppSpacing.xs,
88-
),
89-
selected: activeFilterId == _allFilterId,
90-
showCheckmark: false,
91-
onSelected: (_) {
92-
context.read<HeadlinesFeedBloc>().add(
93-
AllFilterSelected(
94-
adThemeStyle: AdThemeStyle.fromTheme(theme),
95-
),
96-
);
97-
},
52+
// The BlocListener is responsible for reacting to state changes and
53+
// triggering side effects, in this case, scrolling the active chip
54+
// into view.
55+
return BlocListener<HeadlinesFeedBloc, HeadlinesFeedState>(
56+
// Optimize the listener to only fire when the active filter ID changes.
57+
listenWhen: (previous, current) =>
58+
previous.activeFilterId != current.activeFilterId,
59+
listener: (context, state) {
60+
// We use a post-frame callback to ensure that the widget tree has been
61+
// rebuilt and the new active chip (especially the "Custom" one) is
62+
// laid out on the screen before we try to scroll to it.
63+
WidgetsBinding.instance.addPostFrameCallback((_) {
64+
final key = _chipKeys[state.activeFilterId];
65+
if (key?.currentContext != null) {
66+
Scrollable.ensureVisible(
67+
key!.currentContext!,
68+
duration: const Duration(milliseconds: 350),
69+
curve: Curves.easeInOut,
70+
);
71+
}
72+
});
73+
},
74+
child: SizedBox(
75+
height: 52,
76+
child: BlocBuilder<HeadlinesFeedBloc, HeadlinesFeedState>(
77+
builder: (context, state) {
78+
// Clear the keys on each build to prevent the map from growing
79+
// indefinitely with old, stale keys.
80+
_chipKeys.clear();
81+
82+
final savedFilters = state.savedFilters;
83+
final userPreferences = context
84+
.watch<AppBloc>()
85+
.state
86+
.userContentPreferences;
87+
88+
// Determine if the user is following any content to decide whether
89+
// to show the "Followed" filter chip.
90+
final isFollowingItems =
91+
(userPreferences?.followedTopics.isNotEmpty ?? false) ||
92+
(userPreferences?.followedSources.isNotEmpty ?? false) ||
93+
(userPreferences?.followedCountries.isNotEmpty ?? false);
94+
95+
final activeFilterId = state.activeFilterId;
96+
97+
// Lazily create and store a GlobalKey for each chip.
98+
// The key is associated with the Padding widget to ensure the
99+
// entire chip area is scrolled into view.
100+
final allKey = _chipKeys.putIfAbsent(
101+
_allFilterId,
102+
() => GlobalKey(),
103+
);
104+
final followedKey = _chipKeys.putIfAbsent(
105+
_followedFilterId,
106+
() => GlobalKey(),
107+
);
108+
final customKey = _chipKeys.putIfAbsent(
109+
_customFilterId,
110+
() => GlobalKey(),
111+
);
112+
113+
final listView = ListView(
114+
controller: _scrollController,
115+
scrollDirection: Axis.horizontal,
116+
padding: EdgeInsets.zero,
117+
children: [
118+
IconButton(
119+
icon: const Icon(Icons.filter_list),
120+
tooltip: l10n.savedFiltersBarOpenTooltip,
121+
onPressed: () => context.goNamed(Routes.feedFilterName),
122+
),
123+
const VerticalDivider(
124+
width: AppSpacing.md,
125+
indent: AppSpacing.md,
126+
endIndent: AppSpacing.md,
98127
),
99-
),
100-
// Conditionally display the "Followed" filter chip.
101-
if (isFollowingItems)
102128
Padding(
129+
key: allKey,
103130
padding: const EdgeInsets.symmetric(
104131
horizontal: AppSpacing.xs,
105132
),
106133
child: ChoiceChip(
107-
label: Text(l10n.savedFiltersBarFollowedLabel),
134+
label: Text(l10n.savedFiltersBarAllLabel),
108135
labelPadding: const EdgeInsets.symmetric(
109136
horizontal: AppSpacing.xs,
110137
),
111-
selected: activeFilterId == _followedFilterId,
138+
selected: activeFilterId == _allFilterId,
112139
showCheckmark: false,
113140
onSelected: (_) {
114141
context.read<HeadlinesFeedBloc>().add(
115-
FollowedFilterSelected(
142+
AllFilterSelected(
116143
adThemeStyle: AdThemeStyle.fromTheme(theme),
117144
),
118145
);
119146
},
120147
),
121148
),
122-
...savedFilters.map(
123-
(filter) => Padding(
124-
padding: const EdgeInsets.symmetric(
125-
horizontal: AppSpacing.xs,
126-
),
127-
child: ChoiceChip(
128-
label: Text(filter.name),
129-
labelPadding: const EdgeInsets.symmetric(
149+
// Conditionally display the "Followed" filter chip.
150+
if (isFollowingItems)
151+
Padding(
152+
key: followedKey,
153+
padding: const EdgeInsets.symmetric(
130154
horizontal: AppSpacing.xs,
131155
),
132-
selected: activeFilterId == filter.id,
133-
showCheckmark: false,
134-
onSelected: (_) {
135-
context.read<HeadlinesFeedBloc>().add(
136-
SavedFilterSelected(
137-
filter: filter,
138-
adThemeStyle: AdThemeStyle.fromTheme(theme),
139-
),
140-
);
141-
},
142-
),
143-
),
144-
),
145-
if (activeFilterId == _customFilterId)
146-
Padding(
147-
padding: const EdgeInsets.symmetric(
148-
horizontal: AppSpacing.xs,
156+
child: ChoiceChip(
157+
label: Text(l10n.savedFiltersBarFollowedLabel),
158+
labelPadding: const EdgeInsets.symmetric(
159+
horizontal: AppSpacing.xs,
160+
),
161+
selected: activeFilterId == _followedFilterId,
162+
showCheckmark: false,
163+
onSelected: (_) {
164+
context.read<HeadlinesFeedBloc>().add(
165+
FollowedFilterSelected(
166+
adThemeStyle: AdThemeStyle.fromTheme(theme),
167+
),
168+
);
169+
},
170+
),
149171
),
150-
child: ChoiceChip(
151-
label: Text(l10n.savedFiltersBarCustomLabel),
152-
labelPadding: const EdgeInsets.symmetric(
172+
...savedFilters.map((filter) {
173+
final filterKey = _chipKeys.putIfAbsent(
174+
filter.id,
175+
() => GlobalKey(),
176+
);
177+
return Padding(
178+
key: filterKey,
179+
padding: const EdgeInsets.symmetric(
153180
horizontal: AppSpacing.xs,
154181
),
155-
showCheckmark: false,
156-
selected: true,
157-
onSelected: null,
158-
selectedColor: theme.colorScheme.primary.withOpacity(0.2),
159-
labelStyle: TextStyle(
160-
color: theme.colorScheme.onSurface.withOpacity(0.7),
182+
child: ChoiceChip(
183+
label: Text(filter.name),
184+
labelPadding: const EdgeInsets.symmetric(
185+
horizontal: AppSpacing.xs,
186+
),
187+
selected: activeFilterId == filter.id,
188+
showCheckmark: false,
189+
onSelected: (_) {
190+
context.read<HeadlinesFeedBloc>().add(
191+
SavedFilterSelected(
192+
filter: filter,
193+
adThemeStyle: AdThemeStyle.fromTheme(theme),
194+
),
195+
);
196+
},
197+
),
198+
);
199+
}),
200+
if (activeFilterId == _customFilterId)
201+
Padding(
202+
key: customKey,
203+
padding: const EdgeInsets.symmetric(
204+
horizontal: AppSpacing.xs,
205+
),
206+
child: ChoiceChip(
207+
label: Text(l10n.savedFiltersBarCustomLabel),
208+
labelPadding: const EdgeInsets.symmetric(
209+
horizontal: AppSpacing.xs,
210+
),
211+
showCheckmark: false,
212+
selected: true,
213+
onSelected: null,
214+
selectedColor: theme.colorScheme.primary.withOpacity(0.2),
215+
labelStyle: TextStyle(
216+
color: theme.colorScheme.onSurface.withOpacity(0.7),
217+
),
161218
),
162219
),
163-
),
164-
],
165-
);
166-
167-
// Determine if the fade should be shown based on scroll position.
168-
var showStartFade = false;
169-
var showEndFade = false;
170-
if (_scrollController.hasClients &&
171-
_scrollController.position.maxScrollExtent > 0) {
172-
final pixels = _scrollController.position.pixels;
173-
final minScroll = _scrollController.position.minScrollExtent;
174-
final maxScroll = _scrollController.position.maxScrollExtent;
175-
176-
// Show start fade if not at the beginning.
177-
if (pixels > minScroll) {
178-
showStartFade = true;
179-
}
180-
// Show end fade if not at the end.
181-
if (pixels < maxScroll) {
182-
showEndFade = true;
220+
],
221+
);
222+
223+
// Determine if the fade should be shown based on scroll position.
224+
var showStartFade = false;
225+
var showEndFade = false;
226+
if (_scrollController.hasClients &&
227+
_scrollController.position.maxScrollExtent > 0) {
228+
final pixels = _scrollController.position.pixels;
229+
final minScroll = _scrollController.position.minScrollExtent;
230+
final maxScroll = _scrollController.position.maxScrollExtent;
231+
232+
// Show start fade if not at the beginning.
233+
if (pixels > minScroll) {
234+
showStartFade = true;
235+
}
236+
// Show end fade if not at the end.
237+
if (pixels < maxScroll) {
238+
showEndFade = true;
239+
}
183240
}
184-
}
185241

186-
// Define the gradient colors and stops based on fade visibility.
187-
final colors = <Color>[
188-
if (showStartFade) Colors.transparent,
189-
Colors.black,
190-
Colors.black,
191-
if (showEndFade) Colors.transparent,
192-
];
193-
194-
final stops = <double>[
195-
if (showStartFade) 0.0,
196-
if (showStartFade) 0.05 else 0.0,
197-
if (showEndFade) 0.95 else 1.0,
198-
if (showEndFade) 1.0,
199-
];
200-
201-
return ShaderMask(
202-
shaderCallback: (bounds) {
203-
return LinearGradient(
204-
begin: Alignment.centerLeft,
205-
end: Alignment.centerRight,
206-
colors: colors,
207-
stops: stops,
208-
).createShader(bounds);
209-
},
210-
blendMode: BlendMode.dstIn,
211-
child: listView,
212-
);
213-
},
242+
// Define the gradient colors and stops based on fade visibility.
243+
final colors = <Color>[
244+
if (showStartFade) Colors.transparent,
245+
Colors.black,
246+
Colors.black,
247+
if (showEndFade) Colors.transparent,
248+
];
249+
250+
final stops = <double>[
251+
if (showStartFade) 0.0,
252+
if (showStartFade) 0.05 else 0.0,
253+
if (showEndFade) 0.95 else 1.0,
254+
if (showEndFade) 1.0,
255+
];
256+
257+
return ShaderMask(
258+
shaderCallback: (bounds) {
259+
return LinearGradient(
260+
begin: Alignment.centerLeft,
261+
end: Alignment.centerRight,
262+
colors: colors,
263+
stops: stops,
264+
).createShader(bounds);
265+
},
266+
blendMode: BlendMode.dstIn,
267+
child: listView,
268+
);
269+
},
270+
),
214271
),
215272
);
216273
}

0 commit comments

Comments
 (0)