kinetic_gallery 0.1.1 copy "kinetic_gallery: ^0.1.1" to clipboard
kinetic_gallery: ^0.1.1 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'),
              ),
            ],
          ),
        ),
      ),
    ),
  );
}
5
likes
160
points
128
downloads

Documentation

API reference

Publisher

verified publisherleewei0923.com

Weekly Downloads

Gesture-driven Flutter galleries featuring dynamic hero carousels and virtualized, zoomable infinite masonry spaces.

Repository (GitHub)
View/report issues

Topics

#gallery #carousel #animation #image #ui

License

MIT (license)

Dependencies

flutter

More

Packages that depend on kinetic_gallery