Skip to content

Commit efccb45

Browse files
committed
feat(headlines-feed): add scrollable fade effect to saved filters bar on web
- Implement scroll detection and conditional fade rendering - Use ShaderMask with LinearGradient to create fade effect - Add ScrollController and listen for scroll events to trigger updates - Optimize for web platform by checking kIsWeb
1 parent ec7280a commit efccb45

File tree

1 file changed

+75
-14
lines changed

1 file changed

+75
-14
lines changed

lib/headlines-feed/widgets/saved_filters_bar.dart

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:flutter/foundation.dart' show kIsWeb;
12
import 'package:flutter/material.dart';
23
import 'package:flutter_bloc/flutter_bloc.dart';
34
import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart';
@@ -13,11 +14,29 @@ import 'package:ui_kit/ui_kit.dart';
1314
/// This widget allows users to quickly switch between their saved filters,
1415
/// an "All" filter, and a "Custom" filter state. It also provides an entry
1516
/// point to the main filter page.
17+
///
18+
/// On the web, it includes a fade effect at the edges to indicate that the
19+
/// list is scrollable.
1620
/// {@endtemplate}
17-
class SavedFiltersBar extends StatelessWidget {
21+
class SavedFiltersBar extends StatefulWidget {
1822
/// {@macro saved_filters_bar}
1923
const SavedFiltersBar({super.key});
2024

25+
@override
26+
State<SavedFiltersBar> createState() => _SavedFiltersBarState();
27+
}
28+
29+
class _SavedFiltersBarState extends State<SavedFiltersBar> {
30+
final _scrollController = ScrollController();
31+
32+
@override
33+
void initState() {
34+
super.initState();
35+
// Add a listener to rebuild the widget when scrolling occurs,
36+
// which is necessary to update the ShaderMask's gradient.
37+
_scrollController.addListener(() => setState(() {}));
38+
}
39+
2140
static const _allFilterId = 'all';
2241
static const _customFilterId = 'custom';
2342

@@ -32,14 +51,11 @@ class SavedFiltersBar extends StatelessWidget {
3251
builder: (context, state) {
3352
final savedFilters = state.savedFilters;
3453
final activeFilterId = state.activeFilterId;
35-
36-
return ListView(
54+
final listView = ListView(
55+
controller: _scrollController,
3756
scrollDirection: Axis.horizontal,
38-
// Padding is now handled by the parent widget in the page view
39-
// to ensure consistent layout constraints.
4057
padding: EdgeInsets.zero,
4158
children: [
42-
// Button to open the filter page
4359
IconButton(
4460
icon: const Icon(Icons.filter_list),
4561
tooltip: l10n.savedFiltersBarOpenTooltip,
@@ -50,8 +66,6 @@ class SavedFiltersBar extends StatelessWidget {
5066
indent: AppSpacing.md,
5167
endIndent: AppSpacing.md,
5268
),
53-
54-
// "All" filter chip
5569
Padding(
5670
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
5771
child: ChoiceChip(
@@ -67,8 +81,6 @@ class SavedFiltersBar extends StatelessWidget {
6781
},
6882
),
6983
),
70-
71-
// Saved filter chips
7284
...savedFilters.map(
7385
(filter) => Padding(
7486
padding: const EdgeInsets.symmetric(
@@ -89,17 +101,13 @@ class SavedFiltersBar extends StatelessWidget {
89101
),
90102
),
91103
),
92-
93-
// "Custom" filter chip (conditionally rendered)
94104
if (activeFilterId == _customFilterId)
95105
Padding(
96106
padding: const EdgeInsets.symmetric(
97107
horizontal: AppSpacing.xs,
98108
),
99109
child: ChoiceChip(
100110
label: Text(l10n.savedFiltersBarCustomLabel),
101-
// Always selected when visible, but disabled to prevent
102-
// user interaction. It's a status indicator.
103111
showCheckmark: false,
104112
selected: true,
105113
onSelected: null,
@@ -111,8 +119,61 @@ class SavedFiltersBar extends StatelessWidget {
111119
),
112120
],
113121
);
122+
123+
// Determine if the fade should be shown based on scroll position.
124+
var showStartFade = false;
125+
var showEndFade = false;
126+
if (_scrollController.hasClients &&
127+
_scrollController.position.maxScrollExtent > 0) {
128+
final pixels = _scrollController.position.pixels;
129+
final minScroll = _scrollController.position.minScrollExtent;
130+
final maxScroll = _scrollController.position.maxScrollExtent;
131+
132+
// Show start fade if not at the beginning.
133+
if (pixels > minScroll) {
134+
showStartFade = true;
135+
}
136+
// Show end fade if not at the end.
137+
if (pixels < maxScroll) {
138+
showEndFade = true;
139+
}
140+
}
141+
142+
// Define the gradient colors and stops based on fade visibility.
143+
final colors = <Color>[
144+
if (showStartFade) Colors.transparent,
145+
Colors.black,
146+
Colors.black,
147+
if (showEndFade) Colors.transparent,
148+
];
149+
150+
final stops = <double>[
151+
if (showStartFade) 0.0,
152+
if (showStartFade) 0.05 else 0.0,
153+
if (showEndFade) 0.95 else 1.0,
154+
if (showEndFade) 1.0,
155+
];
156+
157+
return ShaderMask(
158+
shaderCallback: (bounds) {
159+
return LinearGradient(
160+
begin: Alignment.centerLeft,
161+
end: Alignment.centerRight,
162+
colors: colors,
163+
stops: stops,
164+
).createShader(bounds);
165+
},
166+
blendMode: BlendMode.dstIn,
167+
child: listView,
168+
);
114169
},
115170
),
116171
);
117172
}
173+
174+
@override
175+
void dispose() {
176+
_scrollController.dispose();
177+
super.dispose();
178+
}
118179
}

0 commit comments

Comments
 (0)