mobius_carousel 0.3.2 copy "mobius_carousel: ^0.3.2" to clipboard
mobius_carousel: ^0.3.2 copied to clipboard

A custom infinite carousel slider with tilted neighbors, rubber-band pull-to-claim, confetti burst, claim dialog, and configurable ripple effects.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:mobius_carousel/mobius_carousel.dart';

import 'bird_palette.dart';

void main() => runApp(const ExampleApp());

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Mobius Carousel Example',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFE91E63)),
        scaffoldBackgroundColor: const Color(0xFFFDF7F8),
      ),
      home: const HomePage(),
    );
  }
}

/// One offer, plus the bird photo that fronts it.
///
/// The photo is the only thing visible on the card: everything the offer
/// would normally show is kept in the tree but hidden (see [_BirdCard]),
/// and the card's accent colour is read off the photo at runtime rather
/// than hard-coded, so the pull-down ripple takes on the bird's colour.
@immutable
class _Bird {
  const _Bird({
    required this.asset,
    required this.species,
    required this.scientificName,
    required this.range,
    required this.fact,
    required this.photographer,
    required this.code,
  });

  /// Asset path of the photograph shown on the card.
  final String asset;

  /// Common name, e.g. "Red-and-green Macaw".
  final String species;

  /// Binomial name, e.g. "Ara chloropterus".
  final String scientificName;

  /// Where the bird lives, phrased to read after "from".
  final String range;

  /// One true sentence about the bird, shown on the details screen.
  final String fact;

  /// Photographer credited in assets/birds/CREDITS.md. Every photo is CC0.
  final String photographer;

  /// Short code shown in the (hidden) card chip and the claim dialog.
  final String code;
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  static const List<_Bird> _birds = [
    _Bird(
      asset: 'assets/birds/red_and_green_macaw.jpg',
      species: 'Red-and-green Macaw',
      scientificName: 'Ara chloropterus',
      range: 'the forests of South America',
      fact: 'One of the largest macaws, with a wingspan of over a metre. '
          'The red lines across its bare cheek are made of tiny feathers, '
          'and the pattern is unique to each bird.',
      photographer: 'Dcoetzee',
      code: 'MACAW20',
    ),
    _Bird(
      asset: 'assets/birds/chilean_flamingo.jpg',
      species: 'Chilean Flamingo',
      scientificName: 'Phoenicopterus chilensis',
      range: 'the lakes of temperate South America',
      fact: 'Its pink comes from carotenoid pigments in the algae and '
          'crustaceans it filters out of the water — a flamingo fed '
          'differently fades to white.',
      photographer: 'Carlota Vidal',
      code: 'FLAM24',
    ),
    _Bird(
      asset: 'assets/birds/sun_conure.jpg',
      species: 'Sun Parakeet',
      scientificName: 'Aratinga solstitialis',
      range: 'north-eastern South America',
      fact: 'Endangered in the wild. Young birds are green and only turn '
          'this orange-gold as they mature.',
      photographer: 'Daderot',
      code: 'CONURE50',
    ),
    _Bird(
      asset: 'assets/birds/yellow_warbler.jpg',
      species: 'Yellow Warbler',
      scientificName: 'Setophaga petechia',
      range: 'the Americas, this one from the Galápagos',
      fact: 'Most populations migrate thousands of kilometres each year, '
          'but the Galápagos birds stay put all their lives.',
      photographer: 'Wmpearl',
      code: 'WARB15',
    ),
    _Bird(
      asset: 'assets/birds/yellow_collared_lovebird.jpg',
      species: 'Yellow-collared Lovebird',
      scientificName: 'Agapornis personatus',
      range: 'the woodlands of northern Tanzania',
      fact: 'Named for how tightly bonded pairs perch — pressed together, '
          'preening each other for long stretches.',
      photographer: 'Adam Kranz',
      code: 'LOVE100',
    ),
    _Bird(
      asset: 'assets/birds/indian_peacock.jpg',
      species: 'Indian Peafowl',
      scientificName: 'Pavo cristatus',
      range: 'the Indian subcontinent',
      fact: 'The national bird of India. The male grows his train of eyed '
          'feathers for the breeding season and sheds it afterwards, '
          'every year.',
      photographer: 'Bernard Spragg. NZ',
      code: 'PEA75',
    ),
  ];

  /// Accent colour per bird, extracted from its photo. Starts empty and
  /// fills in as the images decode; until then the carousel's own default
  /// accent is used, so the first frame never waits on image work.
  final Map<String, Color> _accents = <String, Color>{};

  /// Flip to `true` to bring the offer experience back: the cashback
  /// header and the drag hint become visible again and the pull claims.
  static const bool _offersEnabled = false;

  /// Which bird starts in the center. Passed to the carousel and used for
  /// the header's first frame, so the two cannot disagree.
  static const int _initialIndex = 1;

  /// The bird currently in the center slot, mirrored from the carousel so
  /// the header can name it.
  _Bird _centered = _birds[_initialIndex];

  @override
  void initState() {
    super.initState();
    _loadAccents();
  }

  Future<void> _loadAccents() async {
    for (final bird in _birds) {
      final color = await dominantColorOfAsset(bird.asset);
      if (!mounted) return;
      setState(() => _accents[bird.asset] = color);
    }
  }

  List<MobiusItem> get _items => [
        for (final bird in _birds)
          MobiusItem(
            code: bird.code,
            // The dialog reads "<billAmount> from <provider>".
            provider: bird.range,
            accountNumber: bird.scientificName,
            billAmount: bird.species,
            logo: Icons.flutter_dash,
            // Drives the card glow, the claim dialog and — the point of
            // the demo — the drag-down ripple behind the cards.
            color: _accents[bird.asset],
            data: bird,
          ),
      ];

  @override
  Widget build(BuildContext context) {
    return MobiusCarousel(
      items: _items,
      initialIndex: _initialIndex,
      header: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          // Built and kept alive — its countdown keeps ticking — but not
          // painted and not taking up space while offers are off.
          const Visibility(
            visible: _offersEnabled,
            maintainState: true,
            maintainAnimation: true,
            child: _DemoHeader(),
          ),
          _BirdHeader(bird: _centered, color: _accents[_centered.asset]),
        ],
      ),
      footer: const _DragHint(),
      rippleStyle: MobiusRippleStyle.semiCircle,
      // The bird gallery browses sideways only; pulling a card down to
      // claim is switched off.
      // The pull and its ripple stay; only the claim is held back.
      claimEnabled: _offersEnabled,
      onCenterChanged: (index, _) => setState(() => _centered = _birds[index]),
      cardBuilder: (context, item, isFocused) =>
          _BirdCard(item: item, isFocused: isFocused),
      onCenterCardTap: (item) {
        debugPrint('center tapped: ${item.provider}');
      },
      onOfferClaimed: (item) {
        debugPrint('offer claimed: ${item.provider} — ${item.billAmount}');
      },
      onClaimConfirmed: (context, item) {
        Navigator.of(context).push(
          MaterialPageRoute<void>(
            builder: (_) => _ClaimedDestinationPage(item: item),
          ),
        );
      },
    );
  }
}

/// A card whose face is a bird photograph.
///
/// The offer details are still built — code chip, logo badge, provider,
/// account number, dashed divider and bill amount — but wrapped in a
/// [Visibility] that is `visible: false` while keeping its state, size and
/// animations. They occupy exactly the space they always did and can be
/// switched back on by flipping one flag; they simply are not painted.
class _BirdCard extends StatelessWidget {
  const _BirdCard({required this.item, required this.isFocused});

  final MobiusItem item;
  final bool isFocused;

  /// Flip to `true` to reveal the offer details over the photo again.
  static const bool _showOfferDetails = false;

  @override
  Widget build(BuildContext context) {
    final bird = item.data as _Bird;
    final accent = item.color ?? const Color(0xFFE91E63);

    return DecoratedBox(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(22),
        boxShadow: [
          BoxShadow(
            color: isFocused
                ? accent.withValues(alpha: 0.30)
                : Colors.black.withValues(alpha: 0.08),
            blurRadius: isFocused ? 26 : 14,
            offset: const Offset(0, 10),
          ),
        ],
      ),
      child: ClipRRect(
        borderRadius: BorderRadius.circular(22),
        child: Stack(
          fit: StackFit.expand,
          children: [
            Image.asset(
              bird.asset,
              fit: BoxFit.cover,
              // The photo is the card's whole visual; announce the bird
              // rather than leaving an unlabelled image to a screen reader.
              semanticLabel: bird.species,
            ),
            Visibility(
              visible: _showOfferDetails,
              maintainState: true,
              maintainAnimation: true,
              maintainSize: true,
              maintainInteractivity: false,
              child: _OfferDetails(item: item, accent: accent),
            ),
          ],
        ),
      ),
    );
  }
}

/// The offer content that normally fills the card. Kept in the tree and
/// hidden by [_BirdCard]; unchanged otherwise.
class _OfferDetails extends StatelessWidget {
  const _OfferDetails({required this.item, required this.accent});

  final MobiusItem item;
  final Color accent;

  @override
  Widget build(BuildContext context) {
    final softTint = Color.alphaBlend(
      accent.withValues(alpha: 0.10),
      Colors.white,
    );

    return ColoredBox(
      color: Colors.white,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: double.infinity,
            color: softTint,
            padding: const EdgeInsets.symmetric(vertical: 18),
            child: Text(
              item.code ?? '',
              textAlign: TextAlign.center,
              style: TextStyle(
                color: accent,
                fontSize: 18,
                fontWeight: FontWeight.w800,
                letterSpacing: 1.2,
              ),
            ),
          ),
          const SizedBox(height: 22),
          Container(
            padding: const EdgeInsets.all(14),
            decoration: BoxDecoration(
              color: accent.withValues(alpha: 0.12),
              shape: BoxShape.circle,
            ),
            child: Icon(item.logo ?? Icons.flutter_dash, color: accent),
          ),
          const SizedBox(height: 16),
          Text(
            item.provider ?? '',
            textAlign: TextAlign.center,
            style: const TextStyle(
              fontSize: 22,
              fontWeight: FontWeight.w700,
              color: Color(0xFF1B1B1B),
            ),
          ),
          const SizedBox(height: 6),
          Text(
            item.accountNumber ?? '',
            style: const TextStyle(fontSize: 13, color: Color(0xFF9A9A9A)),
          ),
          const SizedBox(height: 18),
          Text(
            'Bill Amount',
            style: TextStyle(
              color: accent,
              fontSize: 13,
              fontWeight: FontWeight.w600,
            ),
          ),
          const SizedBox(height: 4),
          Text(
            item.billAmount ?? '',
            style: const TextStyle(
              fontSize: 30,
              fontWeight: FontWeight.w800,
              color: Color(0xFF1B1B1B),
            ),
          ),
        ],
      ),
    );
  }
}

/// Header that names whichever bird is currently centered.
///
/// The name and its colour both come from the centered card, so swiping
/// re-reads like turning a page in a field guide.
class _BirdHeader extends StatelessWidget {
  const _BirdHeader({required this.bird, required this.color});

  final _Bird bird;

  /// Accent extracted from this bird's photograph; null until it decodes.
  final Color? color;

  @override
  Widget build(BuildContext context) {
    final accent = color ?? const Color(0xFF6B6B6B);

    return Padding(
      padding: const EdgeInsets.fromLTRB(24, 0, 24, 0),
      child: Column(
        children: [
          const Text(
            'Six birds, six colours',
            style: TextStyle(
              fontSize: 13,
              letterSpacing: 1.6,
              fontWeight: FontWeight.w600,
              color: Color(0xFF9A9A9A),
            ),
          ),
          const SizedBox(height: 10),
          // Swapping the name crossfades instead of snapping, which keeps
          // the header calm while the cards are still moving.
          AnimatedSwitcher(
            duration: const Duration(milliseconds: 260),
            child: Column(
              key: ValueKey<String>(bird.asset),
              children: [
                Text(
                  bird.species,
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    fontSize: 26,
                    fontWeight: FontWeight.w800,
                    color: accent,
                    height: 1.15,
                  ),
                ),
                const SizedBox(height: 6),
                Text(
                  bird.scientificName,
                  style: const TextStyle(
                    fontSize: 14,
                    fontStyle: FontStyle.italic,
                    color: Color(0xFF8C8C8C),
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(height: 14),
          Text(
            'Every colour on this screen is read from the photograph '
            'itself — swipe to change it.',
            textAlign: TextAlign.center,
            style: TextStyle(
              color: const Color(0xFF9A9A9A),
              fontSize: 12.5,
              height: 1.45,
              // Keeps the two-line block from jumping as names change.
              letterSpacing: 0.1,
            ),
          ),
        ],
      ),
    );
  }
}

/// Example header with a live "Reward ends in HH:MM:SS Hrs" countdown.
class _DemoHeader extends StatefulWidget {
  const _DemoHeader();

  @override
  State<_DemoHeader> createState() => _DemoHeaderState();
}

class _DemoHeaderState extends State<_DemoHeader> {
  late int _remainingSeconds;
  Timer? _ticker;

  static const Color _accent = Color(0xFFC2185B);

  @override
  void initState() {
    super.initState();
    _remainingSeconds = 12 * 3600 + 24 * 60 + 30;
    _ticker = Timer.periodic(const Duration(seconds: 1), (_) {
      if (!mounted) return;
      if (_remainingSeconds > 0) {
        setState(() => _remainingSeconds--);
      }
    });
  }

  @override
  void dispose() {
    _ticker?.cancel();
    super.dispose();
  }

  String get _formattedTime {
    final h = _remainingSeconds ~/ 3600;
    final m = (_remainingSeconds % 3600) ~/ 60;
    final s = _remainingSeconds % 60;
    return '${h.toString().padLeft(2, '0')}:'
        '${m.toString().padLeft(2, '0')}:'
        '${s.toString().padLeft(2, '0')}';
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.fromLTRB(24, 0, 24, 0),
      child: Column(
        children: [
          const Text.rich(
            TextSpan(
              style: TextStyle(
                fontSize: 26,
                fontWeight: FontWeight.w800,
                color: _accent,
              ),
              children: [
                TextSpan(text: 'Get '),
                TextSpan(text: '₹20 Cashback'),
              ],
            ),
            textAlign: TextAlign.center,
          ),
          const SizedBox(height: 10),
          const Text(
            'Unlock instant cashback on your\nnext payment',
            textAlign: TextAlign.center,
            style: TextStyle(
              color: Color(0xFF8C8C8C),
              fontSize: 13.5,
              height: 1.45,
            ),
          ),
          const SizedBox(height: 18),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
            decoration: BoxDecoration(
              color: const Color(0xFFFCE4EC),
              borderRadius: BorderRadius.circular(30),
            ),
            child: Text.rich(
              TextSpan(
                style: const TextStyle(
                  color: _accent,
                  fontSize: 13,
                  fontWeight: FontWeight.w600,
                ),
                children: [
                  const TextSpan(text: 'Reward ends in '),
                  TextSpan(
                    text: '$_formattedTime ',
                    style: const TextStyle(fontWeight: FontWeight.w800),
                  ),
                  const TextSpan(text: 'Hrs'),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _DragHint extends StatelessWidget {
  const _DragHint();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Icon(
          Icons.keyboard_double_arrow_down,
          size: 28,
          color: const Color(0xFFC2185B).withValues(alpha: 0.6),
        ),
        const SizedBox(height: 6),
        const Visibility(
          visible: _HomePageState._offersEnabled,
          maintainState: true,
          maintainAnimation: true,
          child: Text(
            'Drag down to Claim the Offer',
            style: TextStyle(
              color: Color(0xFF6B6B6B),
              fontSize: 13,
              fontWeight: FontWeight.w500,
            ),
          ),
        ),
      ],
    );
  }
}

/// Destination screen after a claim: the bird behind the card.
class _ClaimedDestinationPage extends StatelessWidget {
  const _ClaimedDestinationPage({required this.item});

  final MobiusItem item;

  @override
  Widget build(BuildContext context) {
    final bird = item.data! as _Bird;
    final color = item.color ?? const Color(0xFFE91E63);

    return Scaffold(
      backgroundColor: Colors.white,
      body: CustomScrollView(
        slivers: [
          SliverAppBar(
            expandedHeight: 320,
            pinned: true,
            backgroundColor: color,
            foregroundColor: Colors.white,
            flexibleSpace: FlexibleSpaceBar(
              title: Text(
                bird.species,
                style: const TextStyle(
                  // Material 3's FlexibleSpaceBar styles its title with
                  // titleLarge and ignores SliverAppBar.foregroundColor, so
                  // the colour has to be set here or the name renders dark
                  // on a dark photograph.
                  color: Colors.white,
                  fontWeight: FontWeight.w700,
                  shadows: [Shadow(blurRadius: 12, color: Colors.black54)],
                ),
              ),
              background: Stack(
                fit: StackFit.expand,
                children: [
                  Image.asset(
                    bird.asset,
                    fit: BoxFit.cover,
                    semanticLabel: bird.species,
                  ),
                  // Keeps the title legible over a bright photo.
                  const DecoratedBox(
                    decoration: BoxDecoration(
                      gradient: LinearGradient(
                        begin: Alignment.center,
                        end: Alignment.bottomCenter,
                        colors: [Colors.transparent, Colors.black54],
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
          SliverPadding(
            padding: const EdgeInsets.fromLTRB(24, 24, 24, 40),
            sliver: SliverList.list(
              children: [
                Text(
                  bird.scientificName,
                  style: TextStyle(
                    fontSize: 16,
                    fontStyle: FontStyle.italic,
                    color: color,
                    fontWeight: FontWeight.w600,
                  ),
                ),
                const SizedBox(height: 18),
                _DetailRow(
                  icon: Icons.public,
                  label: 'Found in',
                  value: bird.range,
                  color: color,
                ),
                const SizedBox(height: 14),
                _DetailRow(
                  icon: Icons.local_offer_outlined,
                  label: 'Claimed with',
                  value: bird.code,
                  color: color,
                ),
                const SizedBox(height: 24),
                Text(
                  bird.fact,
                  style: const TextStyle(
                    fontSize: 15.5,
                    height: 1.5,
                    color: Color(0xFF3A3A3A),
                  ),
                ),
                const SizedBox(height: 28),
                DecoratedBox(
                  decoration: BoxDecoration(
                    color: Color.alphaBlend(
                      color.withValues(alpha: 0.08),
                      Colors.white,
                    ),
                    borderRadius: BorderRadius.circular(14),
                  ),
                  child: Padding(
                    padding: const EdgeInsets.all(16),
                    child: Row(
                      children: [
                        Container(
                          width: 34,
                          height: 34,
                          decoration: BoxDecoration(
                            color: color,
                            shape: BoxShape.circle,
                          ),
                          // The accent really is this photograph's colour.
                          child: const Icon(
                            Icons.palette_outlined,
                            size: 18,
                            color: Colors.white,
                          ),
                        ),
                        const SizedBox(width: 12),
                        Expanded(
                          child: Text(
                            'This screen is tinted with the dominant colour '
                            'of the photo above, read from the image at run '
                            'time.',
                            style: TextStyle(
                              fontSize: 13,
                              height: 1.4,
                              color: Colors.black.withValues(alpha: 0.7),
                            ),
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
                const SizedBox(height: 16),
                Text(
                  'Photograph by ${bird.photographer} · public domain (CC0) '
                  'via Wikimedia Commons. See assets/birds/CREDITS.md.',
                  style: const TextStyle(fontSize: 11.5, color: Colors.grey),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

/// One labelled fact on the details screen.
class _DetailRow extends StatelessWidget {
  const _DetailRow({
    required this.icon,
    required this.label,
    required this.value,
    required this.color,
  });

  final IconData icon;
  final String label;
  final String value;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Icon(icon, size: 18, color: color),
        const SizedBox(width: 10),
        Text(
          '$label  ',
          style: const TextStyle(
            fontSize: 14,
            color: Color(0xFF8A8A8A),
          ),
        ),
        Expanded(
          child: Text(
            value,
            style: const TextStyle(
              fontSize: 14.5,
              fontWeight: FontWeight.w600,
              color: Color(0xFF1B1B1B),
            ),
          ),
        ),
      ],
    );
  }
}
0
likes
160
points
537
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A custom infinite carousel slider with tilted neighbors, rubber-band pull-to-claim, confetti burst, claim dialog, and configurable ripple effects.

Homepage
Repository (GitHub)
View/report issues

Topics

#carousel #slider #widget #ui #animation

License

MIT (license)

Dependencies

flutter

More

Packages that depend on mobius_carousel