Skip to content

Commit 4b29045

Browse files
committed
sticky_header: Add example app
This is adapted lightly from an example app I made back in the first week of the zulip-flutter project, 2022-12-23, as part of developing the sticky_header library. The example app was extremely helpful then for experimenting with changes and seeing the effects visually and interactively, as well as for print-debugging such experiments. So let's get it into the tree. The main reason I didn't send the example app back then is that it was a whole stand-alone app tree under example/, complete with all the stuff in android/ and ios/ and so on that `flutter create` spits out for setting up a Flutter app. That's pretty voluminous: well over 100 different files totalling about 1.1MB on disk. I did't want to permanently burden the repo with all that, nor have to maintain it all over time. Happily, I realized today that we can skip that, and still have a perfectly good example app, by reusing that infrastructure from the actual Zulip app. That way all we need is a Dart file with a `main` function, corresponding to the old example's `lib/main.dart` which was the only not-from-the-template code in the whole example app. So here it is. Other than moving the Dart file and discarding the rest, the code I wrote back then has been updated to our current formatting style; adjusted slightly for changes in Flutter's Material widgets; and updated for changes I made to the sticky_header API after that first week.
1 parent 55fdfd3 commit 4b29045

File tree

1 file changed

+345
-0
lines changed

1 file changed

+345
-0
lines changed

lib/example/sticky_header.dart

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
/// Example app for exercising the sticky_header library.
2+
///
3+
/// This is useful when developing changes to [StickyHeaderListView],
4+
/// [SliverStickyHeaderList], and [StickyHeaderItem],
5+
/// for experimenting visually with changes.
6+
///
7+
/// To use this example app, run the command:
8+
/// flutter run lib/example/sticky_header.dart
9+
/// or run this file from your IDE.
10+
///
11+
/// One inconvenience: this means the example app will use the same app ID
12+
/// as the actual Zulip app. The app's data remains untouched, though, so
13+
/// a normal `flutter run` will put things back as they were.
14+
/// This inconvenience could be fixed with a bit more work: we'd use
15+
/// `flutter run --flavor`, and define an Android flavor in build.gradle
16+
/// and an Xcode scheme in the iOS build config
17+
/// so as to set the app ID differently.
18+
library;
19+
20+
import 'package:flutter/material.dart';
21+
22+
import '../widgets/sticky_header.dart';
23+
24+
/// Example page using [StickyHeaderListView] and [StickyHeaderItem] in a
25+
/// vertically-scrolling list.
26+
class ExampleVertical extends StatelessWidget {
27+
ExampleVertical({
28+
super.key,
29+
required this.title,
30+
this.reverse = false,
31+
this.headerDirection = AxisDirection.down,
32+
}) : assert(axisDirectionToAxis(headerDirection) == Axis.vertical);
33+
34+
final String title;
35+
final bool reverse;
36+
final AxisDirection headerDirection;
37+
38+
@override
39+
Widget build(BuildContext context) {
40+
final headerAtBottom = axisDirectionIsReversed(headerDirection);
41+
42+
const numSections = 100;
43+
const numPerSection = 10;
44+
return Scaffold(
45+
appBar: AppBar(title: Text(title)),
46+
47+
// Invoke StickyHeaderListView the same way you'd invoke ListView.
48+
// The constructor takes the same arguments.
49+
body: StickyHeaderListView.separated(
50+
reverse: reverse,
51+
reverseHeader: headerAtBottom,
52+
itemCount: numSections,
53+
separatorBuilder: (context, i) => const SizedBox.shrink(),
54+
55+
// Use StickyHeaderItem as an item widget in the ListView.
56+
// A header will float over the item as needed in order to
57+
// "stick" at the edge of the viewport.
58+
//
59+
// You can also include non-StickyHeaderItem items in the list.
60+
// They'll behave just like in a plain ListView.
61+
//
62+
// Each StickyHeaderItem needs to be an item directly in the list, not
63+
// wrapped inside other widgets that affect layout, in order to get
64+
// the sticky-header behavior.
65+
itemBuilder: (context, i) => StickyHeaderItem(
66+
header: WideHeader(i: i),
67+
child: Column(
68+
verticalDirection: headerAtBottom
69+
? VerticalDirection.up : VerticalDirection.down,
70+
children: List.generate(
71+
numPerSection + 1, (j) {
72+
if (j == 0) return WideHeader(i: i);
73+
return WideItem(i: i, j: j-1);
74+
})))));
75+
}
76+
}
77+
78+
/// Example page using [StickyHeaderListView] and [StickyHeaderItem] in a
79+
/// horizontally-scrolling list.
80+
class ExampleHorizontal extends StatelessWidget {
81+
ExampleHorizontal({
82+
super.key,
83+
required this.title,
84+
this.reverse = false,
85+
required this.headerDirection,
86+
}) : assert(axisDirectionToAxis(headerDirection) == Axis.horizontal);
87+
88+
final String title;
89+
final bool reverse;
90+
final AxisDirection headerDirection;
91+
92+
@override
93+
Widget build(BuildContext context) {
94+
final headerAtRight = axisDirectionIsReversed(headerDirection);
95+
const numSections = 100;
96+
const numPerSection = 10;
97+
return Scaffold(
98+
appBar: AppBar(title: Text(title)),
99+
body: StickyHeaderListView.separated(
100+
101+
// StickyHeaderListView and StickyHeaderItem also work for horizontal
102+
// scrolling. Pass `scrollDirection: Axis.horizontal` to the
103+
// StickyHeaderListView constructor, just like for ListView.
104+
scrollDirection: Axis.horizontal,
105+
reverse: reverse,
106+
reverseHeader: headerAtRight,
107+
itemCount: numSections,
108+
separatorBuilder: (context, i) => const SizedBox.shrink(),
109+
itemBuilder: (context, i) => StickyHeaderItem(
110+
header: TallHeader(i: i),
111+
child: Row(
112+
textDirection: headerAtRight ? TextDirection.rtl : TextDirection.ltr,
113+
children: List.generate(
114+
numPerSection + 1,
115+
(j) {
116+
if (j == 0) return TallHeader(i: i);
117+
return TallItem(i: i, j: j-1, numPerSection: numPerSection);
118+
})))));
119+
}
120+
}
121+
122+
////////////////////////////////////////////////////////////////////////////
123+
//
124+
// That's it!
125+
//
126+
// The rest of this file is boring infrastructure for navigating to the
127+
// different examples, and for having some content to put inside them.
128+
//
129+
////////////////////////////////////////////////////////////////////////////
130+
131+
class WideHeader extends StatelessWidget {
132+
const WideHeader({super.key, required this.i});
133+
134+
final int i;
135+
136+
@override
137+
Widget build(BuildContext context) {
138+
return Material(
139+
color: Theme.of(context).colorScheme.primaryContainer,
140+
child: ListTile(
141+
title: Text("Section ${i + 1}",
142+
style: TextStyle(
143+
color: Theme.of(context).colorScheme.onPrimaryContainer))));
144+
}
145+
}
146+
147+
class WideItem extends StatelessWidget {
148+
const WideItem({super.key, required this.i, required this.j});
149+
150+
final int i;
151+
final int j;
152+
153+
@override
154+
Widget build(BuildContext context) {
155+
return ListTile(title: Text("Item ${i + 1}.${j + 1}"));
156+
}
157+
}
158+
159+
class TallHeader extends StatelessWidget {
160+
const TallHeader({super.key, required this.i});
161+
162+
final int i;
163+
164+
@override
165+
Widget build(BuildContext context) {
166+
final contents = Column(children: [
167+
Text("Section ${i + 1}",
168+
style: TextStyle(
169+
fontWeight: FontWeight.bold,
170+
color: Theme.of(context).colorScheme.onPrimaryContainer)),
171+
const SizedBox(height: 8),
172+
const Expanded(child: SizedBox.shrink()),
173+
const SizedBox(height: 8),
174+
const Text("end"),
175+
]);
176+
177+
return Container(
178+
alignment: Alignment.center,
179+
child: Card(
180+
color: Theme.of(context).colorScheme.primaryContainer,
181+
child: Padding(padding: const EdgeInsets.all(8), child: contents)));
182+
}
183+
}
184+
185+
class TallItem extends StatelessWidget {
186+
const TallItem({super.key,
187+
required this.i,
188+
required this.j,
189+
required this.numPerSection,
190+
});
191+
192+
final int i;
193+
final int j;
194+
final int numPerSection;
195+
196+
@override
197+
Widget build(BuildContext context) {
198+
final heightFactor = (1 + j) / numPerSection;
199+
200+
final contents = Column(children: [
201+
Text("Item ${i + 1}.${j + 1}"),
202+
const SizedBox(height: 8),
203+
Expanded(
204+
child: FractionallySizedBox(
205+
heightFactor: heightFactor,
206+
child: ColoredBox(
207+
color: Theme.of(context).colorScheme.secondary,
208+
child: const SizedBox(width: 4)))),
209+
const SizedBox(height: 8),
210+
const Text("end"),
211+
]);
212+
213+
return Container(
214+
alignment: Alignment.center,
215+
child: Card(
216+
child: Padding(padding: const EdgeInsets.all(8), child: contents)));
217+
}
218+
}
219+
220+
enum _ExampleType { vertical, horizontal }
221+
222+
class MainPage extends StatelessWidget {
223+
const MainPage({super.key});
224+
225+
@override
226+
Widget build(BuildContext context) {
227+
final verticalItems = [
228+
_buildItem(context, _ExampleType.vertical,
229+
primary: true,
230+
title: 'Scroll down, headers at top (a standard list)',
231+
headerDirection: AxisDirection.down),
232+
_buildItem(context, _ExampleType.vertical,
233+
title: 'Scroll up, headers at top',
234+
reverse: true,
235+
headerDirection: AxisDirection.down),
236+
_buildItem(context, _ExampleType.vertical,
237+
title: 'Scroll down, headers at bottom',
238+
headerDirection: AxisDirection.up),
239+
_buildItem(context, _ExampleType.vertical,
240+
title: 'Scroll up, headers at bottom',
241+
reverse: true,
242+
headerDirection: AxisDirection.up),
243+
];
244+
final horizontalItems = [
245+
_buildItem(context, _ExampleType.horizontal,
246+
title: 'Scroll right, headers at left',
247+
headerDirection: AxisDirection.right),
248+
_buildItem(context, _ExampleType.horizontal,
249+
title: 'Scroll left, headers at left',
250+
reverse: true,
251+
headerDirection: AxisDirection.right),
252+
_buildItem(context, _ExampleType.horizontal,
253+
title: 'Scroll right, headers at right',
254+
headerDirection: AxisDirection.left),
255+
_buildItem(context, _ExampleType.horizontal,
256+
title: 'Scroll left, headers at right',
257+
reverse: true,
258+
headerDirection: AxisDirection.left),
259+
];
260+
return Scaffold(
261+
appBar: AppBar(title: const Text('Sticky Headers example')),
262+
body: CustomScrollView(slivers: [
263+
SliverToBoxAdapter(
264+
child: Padding(
265+
padding: const EdgeInsets.only(top: 24),
266+
child: Center(
267+
child: Text("Vertical lists",
268+
style: Theme.of(context).textTheme.headlineMedium)))),
269+
SliverPadding(
270+
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
271+
sliver: SliverGrid.count(
272+
childAspectRatio: 2,
273+
crossAxisCount: 2,
274+
children: verticalItems)),
275+
SliverToBoxAdapter(
276+
child: Padding(
277+
padding: const EdgeInsets.only(top: 24),
278+
child: Center(
279+
child: Text("Horizontal lists",
280+
style: Theme.of(context).textTheme.headlineMedium)))),
281+
SliverPadding(
282+
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
283+
sliver: SliverGrid.count(
284+
childAspectRatio: 2,
285+
crossAxisCount: 2,
286+
children: horizontalItems)),
287+
]));
288+
}
289+
290+
Widget _buildItem(BuildContext context, _ExampleType exampleType, {
291+
required String title,
292+
bool reverse = false,
293+
required AxisDirection headerDirection,
294+
bool primary = false,
295+
}) {
296+
Widget page;
297+
switch (exampleType) {
298+
case _ExampleType.vertical:
299+
page = ExampleVertical(
300+
title: title, reverse: reverse, headerDirection: headerDirection);
301+
break;
302+
case _ExampleType.horizontal:
303+
page = ExampleHorizontal(
304+
title: title, reverse: reverse, headerDirection: headerDirection);
305+
break;
306+
}
307+
308+
var label = Text(title,
309+
textAlign: TextAlign.center,
310+
style: TextStyle(
311+
inherit: true,
312+
fontSize: Theme.of(context).textTheme.titleLarge?.fontSize));
313+
var buttonStyle = primary
314+
? null
315+
: ElevatedButton.styleFrom(
316+
foregroundColor: Theme.of(context).colorScheme.onSecondary,
317+
backgroundColor: Theme.of(context).colorScheme.secondary);
318+
return Container(
319+
padding: const EdgeInsets.all(16),
320+
child: ElevatedButton(
321+
style: buttonStyle,
322+
onPressed: () => Navigator.of(context)
323+
.push(MaterialPageRoute<void>(builder: (_) => page)),
324+
child: label));
325+
}
326+
}
327+
328+
class ExampleApp extends StatelessWidget {
329+
const ExampleApp({super.key});
330+
331+
@override
332+
Widget build(BuildContext context) {
333+
return MaterialApp(
334+
title: 'Sticky Headers example',
335+
theme: ThemeData(
336+
colorScheme:
337+
ColorScheme.fromSeed(seedColor: const Color(0xff3366cc))),
338+
home: const MainPage(),
339+
);
340+
}
341+
}
342+
343+
void main() {
344+
runApp(const ExampleApp());
345+
}

0 commit comments

Comments
 (0)