kinetic_gallery 0.1.0
kinetic_gallery: ^0.1.0 copied to clipboard
Gesture-driven Flutter galleries featuring dynamic hero carousels and virtualized, zoomable infinite masonry spaces.
example/lib/main.dart
import 'dart:math' as math;
import 'network_photo.dart';
import 'infinite_space_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:kinetic_gallery/kinetic_gallery.dart';
final evaluationSeed = DateTime.now().millisecondsSinceEpoch.toString();
void main() => runApp(const GalleryApp());
class GalleryApp extends StatelessWidget {
const GalleryApp({super.key, this.networkImages = true});
final bool networkImages;
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Kinetic Gallery',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xff007aff),
surface: Colors.white,
),
scaffoldBackgroundColor: const Color(0xfff5f5f7),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xfff5f5f7),
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: true,
titleTextStyle: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Color(0xff1d1d1f),
),
),
dividerTheme: const DividerThemeData(color: Color(0xffe5e5ea)),
sliderTheme: const SliderThemeData(trackHeight: 3),
),
home: GalleryHome(networkImages: networkImages),
routes: {
for (final kind in DemoKind.values)
'/${kind.name}': (_) =>
Playground(kind: kind, networkImages: networkImages),
},
);
}
class GalleryHome extends StatelessWidget {
const GalleryHome({super.key, this.networkImages = true});
final bool networkImages;
Widget photo(int i) => networkImages
? NetworkPhoto(seed: evaluationSeed, index: i)
: ArtCard(index: i);
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: ListView(
padding: const EdgeInsets.all(28),
children: [
const SizedBox(height: 16),
const Text(
'Kinetic Gallery',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w600,
letterSpacing: -1,
color: Color(0xff1d1d1f),
),
),
const SizedBox(height: 8),
const Text(
'Explore motion.',
style: TextStyle(fontSize: 15, color: Color(0xff6e6e73)),
),
const SizedBox(height: 32),
for (final kind in DemoKind.values)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: InkWell(
borderRadius: BorderRadius.circular(24),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => Playground(
kind: kind,
networkImages: networkImages,
),
),
),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
kind == DemoKind.depth
? 'Infinite'
: 'Carousel',
style: TextStyle(
letterSpacing: .2,
fontWeight: FontWeight.w700,
fontSize: 20,
color: const Color(0xff1d1d1f),
),
),
const Spacer(),
Icon(
Icons.chevron_right,
size: 20,
color: const Color(0xff86868b),
),
],
),
const SizedBox(height: 20),
IgnorePointer(
child: SizedBox(
height: 160,
child: switch (kind) {
DemoKind.hero => HeroCarousel.builder(
itemCount: 8,
itemBuilder: (_, i) => photo(i),
height: 160,
viewportFraction: .35,
),
DemoKind.depth => InfiniteCardSpace.builder(
cardSize: const Size(100, 125),
heightPattern: const [
.76,
1.22,
.94,
1.48,
1.06,
],
spacing: 16,
itemBuilder: (_, x, y) =>
photo((x + y * 5) % 12),
),
},
),
),
const SizedBox(height: 16),
Text(
kind.subtitle,
style: TextStyle(
fontSize: 16,
color: const Color(0xff6e6e73),
),
),
],
),
),
),
),
],
),
),
),
),
);
}
enum DemoKind {
hero,
depth;
String get subtitle => switch (this) {
hero => 'Swipe to browse.',
depth => 'Pan. Pinch. Explore.',
};
}
class Playground extends StatefulWidget {
const Playground({super.key, required this.kind, this.networkImages = true});
final bool networkImages;
final DemoKind kind;
@override
State<Playground> createState() => _PlaygroundState();
}
class _PlaygroundState extends State<Playground> {
KineticCarouselController carousel = KineticCarouselController(
initialIndex: 2,
);
double spacing = 8, scale = .78, opacity = .58, fraction = .56;
bool loop = false, custom = false, cards = false;
int index = 2;
String photoSeed = evaluationSeed;
void reset() {
setState(() {
spacing = 8;
scale = .78;
opacity = .58;
fraction = .56;
loop = false;
custom = false;
cards = false;
});
}
@override
void dispose() {
carousel.dispose();
super.dispose();
}
String get code =>
"HeroCarousel.builder(\n itemCount: items.length,\n itemBuilder: buildItem,\n viewportFraction: ${fraction.toStringAsFixed(2)},\n inactiveScale: ${scale.toStringAsFixed(2)},\n inactiveOpacity: ${opacity.toStringAsFixed(2)},\n spacing: ${spacing.toStringAsFixed(1)},\n loop: $loop,\n)";
Widget item(BuildContext context, int i) => cards
? InteractiveCard(index: i)
: widget.networkImages
? NetworkPhoto(key: ValueKey('$photoSeed-$i'), seed: photoSeed, index: i)
: ArtCard(index: i);
Widget transform(
BuildContext context,
Widget child,
GalleryItemMetrics metrics,
) => Transform.rotate(
angle: metrics.distance.clamp(-3.0, 3.0) * .025,
child: child,
);
Widget get demo => Center(
child: HeroCarousel.builder(
itemCount: 9,
itemBuilder: item,
controller: carousel,
viewportFraction: fraction,
borderRadius: BorderRadius.circular(12),
spacing: spacing,
inactiveScale: scale,
inactiveOpacity: opacity,
loop: loop,
transformBuilder: custom ? transform : null,
onIndexChanged: (i) => setState(() => index = i),
),
);
Widget slider(
String label,
double value,
double min,
double max,
ValueChanged<double> update, {
int? divisions,
}) => Column(
children: [
Row(
children: [
Text(label),
const Spacer(),
Text(
value.toStringAsFixed(value > 10 ? 0 : 2),
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
Slider(
value: value,
min: min,
max: max,
divisions: divisions,
onChanged: (v) => setState(() => update(v)),
),
],
);
Widget get controlPanel => Material(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.all(24),
child: controls,
),
);
Widget get controls => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text(
'Controls',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
),
TextButton(onPressed: reset, child: const Text('Reset')),
],
),
const SizedBox(height: 16),
if (widget.kind == DemoKind.hero) ...[
slider('Scale', scale, .5, 1, (v) => scale = v),
slider('Opacity', opacity, .1, 1, (v) => opacity = v),
slider('Viewport fraction', fraction, .25, .85, (v) => fraction = v),
],
slider('Spacing', spacing, 0, 24, (v) => spacing = v),
...[
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Loop'),
value: loop,
onChanged: (v) => setState(() => loop = v),
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Rotation'),
value: custom,
onChanged: (v) => setState(() => custom = v),
),
],
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Interactive cards'),
value: cards,
onChanged: (v) => setState(() => cards = v),
),
const Divider(height: 32),
Text('Index: $index', key: const ValueKey('status')),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton(
onPressed: carousel.previous,
child: const Text('Previous'),
),
OutlinedButton(onPressed: carousel.next, child: const Text('Next')),
OutlinedButton(
onPressed: () => carousel.animateTo(5),
child: const Text('Animate to 5'),
),
OutlinedButton(
onPressed: () => carousel.jumpTo(0),
child: const Text('Jump to 0'),
),
],
),
const SizedBox(height: 20),
const Text(
'Swipe or use arrow keys.',
style: TextStyle(color: Color(0xff6e6e73)),
),
],
);
@override
Widget build(BuildContext context) => widget.kind == DemoKind.depth
? InfiniteSpacePage(networkImages: widget.networkImages)
: Scaffold(
appBar: AppBar(title: Text('Carousel')),
body: LayoutBuilder(
builder: (context, c) {
final preview = Container(
height: 400,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: widget.kind == DemoKind.depth
? const Color(0xff111b18)
: Colors.white,
borderRadius: BorderRadius.circular(24),
),
child: demo,
);
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.kind.subtitle,
style: const TextStyle(fontSize: 20),
),
if (widget.networkImages) ...[
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Shuffle'),
onPressed: () {
if (mounted) {
setState(() {
photoSeed = DateTime.now()
.microsecondsSinceEpoch
.toString();
});
}
},
),
],
),
const Text('Photos by Lorem Picsum / Unsplash'),
],
const SizedBox(height: 24),
if (c.maxWidth > 850)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: preview),
const SizedBox(width: 32),
Expanded(flex: 2, child: controlPanel),
],
)
else ...[
preview,
const SizedBox(height: 24),
controlPanel,
],
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xffffffff),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text(
'Code',
style: TextStyle(
letterSpacing: .2,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'Copy code',
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: code),
);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar(
content: Text(
'Configuration copied',
),
),
);
}
},
icon: const Icon(Icons.copy),
),
],
),
SelectableText(
code,
style: const TextStyle(
fontFamily: 'monospace',
height: 1.6,
),
),
],
),
),
const SizedBox(height: 32),
],
),
),
),
);
},
),
);
}
/// Original vector landscapes: offline and independent of image services.
class ArtCard extends StatelessWidget {
const ArtCard({super.key, required this.index});
final int index;
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CustomPaint(
painter: LandscapePainter(index),
child: const SizedBox.expand(),
),
);
}
class LandscapePainter extends CustomPainter {
LandscapePainter(this.index);
final int index;
static const palettes = [
[Color(0xffdbc9a3), Color(0xff839c84), Color(0xff3b645c)],
[Color(0xffedcbb2), Color(0xffba8372), Color(0xff754e51)],
[Color(0xffb7cbd2), Color(0xff72909f), Color(0xff294b62)],
[Color(0xffe4d4a9), Color(0xffa9a376), Color(0xff667753)],
[Color(0xffddbd9a), Color(0xffbf7c55), Color(0xff6f4b39)],
];
@override
void paint(Canvas canvas, Size size) {
final colors = palettes[index % palettes.length];
canvas.drawRect(
Offset.zero & size,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colors[0], colors[1]],
).createShader(Offset.zero & size),
);
canvas.drawCircle(
Offset(size.width * .7, size.height * .25),
size.shortestSide * .12,
Paint()..color = const Color(0xffffefce),
);
for (var layer = 0; layer < 3; layer++) {
final path = Path()..moveTo(0, size.height);
for (var x = 0.0; x <= size.width + 4; x += 4) {
final y =
size.height * (.53 + layer * .15) +
math.sin(x / size.width * 5 + index + layer) * size.height * .09;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(
path,
Paint()..color = Color.lerp(colors[1], colors[2], layer / 2)!,
);
}
}
@override
bool shouldRepaint(LandscapePainter old) => old.index != index;
}
class InteractiveCard extends StatefulWidget {
const InteractiveCard({super.key, required this.index});
final int index;
@override
State<InteractiveCard> createState() => _InteractiveCardState();
}
class _InteractiveCardState extends State<InteractiveCard>
with SingleTickerProviderStateMixin {
late final AnimationController motion = AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
)..repeat();
int count = 0;
@override
void dispose() {
motion.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ColoredBox(
color: const Color(0xffe8f0fe),
child: LayoutBuilder(
builder: (context, c) => FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: 160,
height: 160,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
RotationTransition(
turns: motion,
child: const Icon(
Icons.blur_on,
size: 40,
color: Color(0xff007aff),
),
),
Text('Card ${widget.index}'),
TextButton(
onPressed: () => setState(() => count++),
child: Text('Count $count'),
),
],
),
),
),
),
);
}