Skip to content

Commit 00718e0

Browse files
committed
feat: Enhance scroll behavior in detail loading page with external scroll position
1 parent 3e3c874 commit 00718e0

3 files changed

Lines changed: 140 additions & 88 deletions

File tree

lib/main.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ class _App extends ConsumerState<App> {
219219
data: c.themeData,
220220
child: FToaster(
221221
child: MaterialApp.router(
222+
showPerformanceOverlay: kProfileMode,
222223
supportedLocales: FLocalizations.supportedLocales,
223224
key: ValueKey(c.language),
224225
theme: c.themeData.toApproximateMaterialTheme(),

lib/ui/core/scaffold/custom_silver_header.dart

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,11 +278,24 @@ class SimpleSliverHeaderDelegate extends BaseSliverHeaderDelegate {
278278
}
279279

280280
/// Generic delegate that accepts a builder function for full control.
281+
///
282+
/// By default the [builder] receives the sliver's own `shrinkOffset` (and a
283+
/// normalized `progress`). That value only changes while the *enclosing*
284+
/// [CustomScrollView] scrolls. When the header lives inside a layout where the
285+
/// body is a separate (nested) scroll view — e.g. a [MiruScaffold] whose
286+
/// `mobileBody` scrolls on its own — the outer sliver never receives a changing
287+
/// `shrinkOffset`, so the header would never update.
288+
///
289+
/// Pass [scrollPosition] (a [ValueListenable] fed by the real scroll, such as
290+
/// the [MiruScaffold.onScrollChange] callback) to drive the header from that
291+
/// scroll position instead. The returned widget then rebuilds on every change
292+
/// via a [ValueListenableBuilder] without requiring the sliver itself to scroll.
281293
class CustomSliverHeaderDelegate extends BaseSliverHeaderDelegate {
282294
const CustomSliverHeaderDelegate({
283295
required this.builder,
284296
required super.maxExtent,
285297
super.minExtent = 0.0,
298+
this.scrollPosition,
286299
});
287300

288301
final Widget Function(
@@ -292,10 +305,25 @@ class CustomSliverHeaderDelegate extends BaseSliverHeaderDelegate {
292305
)
293306
builder;
294307

308+
/// Optional external scroll position. When provided, the header is driven by
309+
/// this value (via a [ValueListenableBuilder]) instead of the sliver's own
310+
/// `shrinkOffset`. This is required when the header's enclosing scroll view
311+
/// does not actually scroll (e.g. the body is a nested scroll view).
312+
final ValueListenable<double>? scrollPosition;
313+
295314
@override
296315
Widget buildContent(BuildContext context, double shrinkOffset) {
297-
final progress = (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0);
298-
return builder(context, shrinkOffset, progress);
316+
if (scrollPosition == null) {
317+
final progress = (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0);
318+
return builder(context, shrinkOffset, progress);
319+
}
320+
return ValueListenableBuilder<double>(
321+
valueListenable: scrollPosition!,
322+
builder: (context, offset, _) {
323+
final progress = (offset / (maxExtent - minExtent)).clamp(0.0, 1.0);
324+
return builder(context, offset, progress);
325+
},
326+
);
299327
}
300328

301329
@override

lib/ui/features/detail/detail_loading_page.dart

Lines changed: 109 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@ import 'package:miru_alpha/ui/features/detail/desktop_loaded_page.dart';
77
import 'package:miru_alpha/ui/features/detail/mobile_loaded_page.dart';
88
import 'package:miru_alpha/provider/detial_provider.dart';
99
import 'package:miru_alpha/provider/extension_provider.dart';
10-
import 'package:miru_alpha/utils/core/log.dart';
1110
import 'package:miru_alpha/utils/router/page_entry.dart';
12-
1311
import 'package:miru_alpha/ui/core/index.dart';
1412
import 'package:smooth_sheets/smooth_sheets.dart';
1513
import './widget/index.dart';
@@ -28,6 +26,20 @@ class DetailLoadingPage extends StatefulHookConsumerWidget {
2826
}
2927

3028
class _DetailLoadPageState extends ConsumerState<DetailLoadingPage> {
29+
/// Mirrors the detail page's scroll offset. The body (`MobileLoadedPage`)
30+
/// scrolls inside its own [CustomScrollView], so the outer sliver header
31+
/// never receives a changing `shrinkOffset`. We feed the real scroll offset
32+
/// here (via [MiruScaffold.onScrollChange]) and drive the header from it
33+
/// instead, so the header's title animation (and any logging) updates on
34+
/// scroll.
35+
final ValueNotifier<double> _scrollNotifier = ValueNotifier(0.0);
36+
37+
@override
38+
void dispose() {
39+
_scrollNotifier.dispose();
40+
super.dispose();
41+
}
42+
3143
@override
3244
Widget build(BuildContext context) {
3345
final detailPr = detialProvider(widget.detailUrl, meta: widget.meta);
@@ -37,6 +49,7 @@ class _DetailLoadPageState extends ConsumerState<DetailLoadingPage> {
3749
final favorite = ref.watch(detailPr.select((value) => value.favorite));
3850
return detial.when(
3951
data: (detial) => MiruScaffold.mobile(
52+
onScrollChange: (offset, progress) => _scrollNotifier.value = offset,
4053
snappingOffsets: const [
4154
AbsoluteSheetOffset(150),
4255
ProportionalToViewportSheetOffset(0.5),
@@ -52,107 +65,117 @@ class _DetailLoadPageState extends ConsumerState<DetailLoadingPage> {
5265
],
5366
sliverHeaders: [
5467
CustomSliverHeaderDelegate(
55-
maxExtent: 120,
56-
minExtent: 60,
68+
maxExtent: 50,
69+
minExtent: 50,
70+
scrollPosition: _scrollNotifier,
5771
builder: (context, offset, progress) {
58-
logger.info('progress: $progress', 'offset: $offset');
59-
final titleVisible = progress > 0.3;
60-
return Padding(
61-
padding: EdgeInsetsGeometry.only(right: 10, left: 5, bottom: 0),
62-
child: Row(
63-
children: [
64-
GestureDetector(
65-
onTap: () {
66-
Navigator.of(context).pop();
67-
},
68-
child: Padding(
69-
padding: const EdgeInsets.only(
70-
right: 12.0,
71-
top: 4,
72-
left: 10,
73-
),
74-
child: Icon(
75-
FLucideIcons.chevronLeft,
76-
size: 28,
77-
color: context.theme.colors.primary,
78-
),
72+
final titleVisible = offset > 285;
73+
return Row(
74+
children: [
75+
GestureDetector(
76+
onTap: () {
77+
Navigator.of(context).pop();
78+
},
79+
child: Padding(
80+
padding: const EdgeInsets.only(
81+
right: 12.0,
82+
top: 4,
83+
left: 10,
84+
),
85+
child: Icon(
86+
FLucideIcons.chevronLeft,
87+
size: 28,
88+
color: context.theme.colors.primary,
7989
),
8090
),
81-
Expanded(
82-
child: AnimatedOpacity(
83-
opacity: titleVisible ? 1.0 : 0.0,
91+
),
92+
Expanded(
93+
child: AnimatedOpacity(
94+
opacity: titleVisible ? 1.0 : 0.0,
95+
duration: const Duration(milliseconds: 150),
96+
curve: Curves.easeInOut,
97+
child: AnimatedSlide(
98+
offset: titleVisible
99+
? Offset.zero
100+
: const Offset(0, -0.3),
84101
duration: const Duration(milliseconds: 150),
85102
curve: Curves.easeInOut,
86-
child: AnimatedSlide(
87-
offset: titleVisible
88-
? Offset.zero
89-
: const Offset(0, -0.3),
90-
duration: const Duration(milliseconds: 150),
91-
curve: Curves.easeInOut,
92-
child: Padding(
93-
padding: const EdgeInsets.only(
94-
top: 8.0,
95-
bottom: 8.0,
96-
),
97-
child: Text(
103+
child: Column(
104+
crossAxisAlignment: .start,
105+
children: [
106+
Text(
98107
detial.title,
108+
maxLines: 1,
99109
style: TextStyle(
100-
height: 1.2,
101110
fontWeight: FontWeight.bold,
102-
fontSize: 20,
111+
fontSize: 18,
103112
color: context.theme.colors.primary,
104113
),
114+
// Let the title fill the full width of the Expanded
115+
// and wrap onto multiple lines instead of being
116+
// clamped to a single ellipsized line.
117+
softWrap: true,
118+
),
119+
Text(
120+
widget.meta.name,
105121
maxLines: 1,
106-
overflow: TextOverflow.ellipsis,
122+
style: TextStyle(
123+
fontWeight: FontWeight.bold,
124+
fontSize: 11,
125+
color: context.theme.colors.mutedForeground,
126+
),
127+
// Let the title fill the full width of the Expanded
128+
// and wrap onto multiple lines instead of being
129+
// clamped to a single ellipsized line.
130+
softWrap: true,
107131
),
108-
),
132+
],
109133
),
110134
),
111135
),
112-
Spacer(),
113-
FButton.icon(
114-
variant: .ghost,
115-
onPress: () {
116-
context.push(
117-
'/mobileWebView',
118-
extra: WebviewParam(
119-
meta: widget.meta,
120-
url: widget.detailUrl,
121-
),
122-
);
123-
},
124-
child: Icon(
125-
FLucideIcons.globe,
126-
size: 28,
127-
color: context.theme.colors.primary,
128-
),
136+
),
137+
FButton.icon(
138+
variant: .ghost,
139+
onPress: () {
140+
context.push(
141+
'/mobileWebView',
142+
extra: WebviewParam(
143+
meta: widget.meta,
144+
url: widget.detailUrl,
145+
),
146+
);
147+
},
148+
child: Icon(
149+
FLucideIcons.globe,
150+
size: 24,
151+
color: context.theme.colors.primary,
129152
),
130-
FButton.icon(
131-
variant: .ghost,
132-
onPress: () {
133-
if (favorite != null) {
134-
ref.read(detailPr.notifier).removeFavorite(favorite);
135-
return;
136-
}
137-
showDialog(
138-
context: context,
139-
builder: (context) => FavoriteDialog(
140-
meta: widget.meta,
141-
detailUrl: widget.detailUrl,
142-
detail: detial,
143-
detailPr: detailPr,
144-
),
145-
);
146-
},
147-
child: HeartButton(
148-
size: 28,
149-
activeColor: context.theme.colors.primary,
150-
inactiveColor: context.theme.colors.primary,
151-
isLiked: favorite != null,
152-
),
153+
),
154+
FButton.icon(
155+
variant: .ghost,
156+
onPress: () {
157+
if (favorite != null) {
158+
ref.read(detailPr.notifier).removeFavorite(favorite);
159+
return;
160+
}
161+
showDialog(
162+
context: context,
163+
builder: (context) => FavoriteDialog(
164+
meta: widget.meta,
165+
detailUrl: widget.detailUrl,
166+
detail: detial,
167+
detailPr: detailPr,
168+
),
169+
);
170+
},
171+
child: HeartButton(
172+
size: 24,
173+
activeColor: context.theme.colors.primary,
174+
inactiveColor: context.theme.colors.primary,
175+
isLiked: favorite != null,
153176
),
154-
],
155-
),
177+
),
178+
],
156179
);
157180
},
158181
),

0 commit comments

Comments
 (0)