scroll_spy 0.2.1 copy "scroll_spy: ^0.2.1" to clipboard
scroll_spy: ^0.2.1 copied to clipboard

Compute focused items and a stable primary item in a scrollable viewport (feed/autoplay/analytics).

example/lib/main.dart

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

void main() {
  runApp(const ShowcaseApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Scroll Spy Showcase',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        brightness: Brightness.dark,
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.indigoAccent,
          brightness: Brightness.dark,
        ),
        scaffoldBackgroundColor: const Color(0xFF0F0F0F),
      ),
      home: const FeedPage(),
    );
  }
}

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

  @override
  State<FeedPage> createState() => _FeedPageState();
}

class _FeedPageState extends State<FeedPage> {
  // 1. Create a Focus Controller
  final ScrollSpyController<int> _focusController = ScrollSpyController();
  final ScrollController _scrollController = ScrollController();

  bool _autoScrolling = false;
  bool _showDebug = true;

  @override
  void dispose() {
    _focusController.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  void _toggleAutoScroll() {
    if (_autoScrolling) {
      setState(() => _autoScrolling = false);
      // Stop scrolling
      _scrollController.position.hold(() {});
      return;
    }

    setState(() => _autoScrolling = true);
    _startAutoScroll();
  }

  Future<void> _startAutoScroll() async {
    while (_autoScrolling && mounted) {
      if (!_scrollController.hasClients) break;
      final max = _scrollController.position.maxScrollExtent;
      final current = _scrollController.offset;

      if (current >= max) {
        _scrollController.jumpTo(0);
      }

      // Smooth auto scroll to next item approx
      await _scrollController.animateTo(
        current + 400,
        duration: const Duration(seconds: 2),
        curve: Curves.easeInOutSine,
      );
      if (!mounted) break;
      // Pause briefly on the item
      await Future.delayed(const Duration(milliseconds: 500));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      extendBodyBehindAppBar: true,
      appBar: AppBar(
        title: const Text('Scroll Spy'),
        backgroundColor: Colors.transparent,
        elevation: 0,
        flexibleSpace: Container(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Colors.black.withValues(alpha: 0.8), Colors.transparent],
            ),
          ),
        ),
        actions: [
          IconButton(
            icon: Icon(_showDebug ? Icons.layers_clear : Icons.layers),
            tooltip: 'Toggle Focus Overlay',
            onPressed: () => setState(() => _showDebug = !_showDebug),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: _toggleAutoScroll,
        icon: Icon(_autoScrolling ? Icons.stop : Icons.play_arrow),
        label: Text(_autoScrolling ? 'Stop Demo' : 'Auto Scroll'),
        backgroundColor: Theme.of(context).colorScheme.primary,
        foregroundColor: Theme.of(context).colorScheme.onPrimary,
      ),
      // 2. Wrap your list in a ScrollSpyScope
      body: ScrollSpyScope<int>(
        controller: _focusController,
        // Define the "Focus Zone" (e.g. center 300px)
        region: ScrollSpyRegion.zone(
          anchor: const ScrollSpyAnchor.fraction(0.5),
          extentPx: 300,
        ),
        // Define how to pick the "Primary" item (Closest to center)
        policy: const ScrollSpyPolicy.closestToAnchor(),
        // Optional: Debug overlay to visualize the zone
        debug: _showDebug,
        debugConfig: const ScrollSpyDebugConfig(
          showFocusRegion: true,
          showLabels: true,
          showPrimaryOutline: true,
          visibleFillOpacity: 0.0, // Clean look, just outlines
        ),
        child: ListView.builder(
          controller: _scrollController,
          itemCount: 20,
          // Make items large enough to be interesting
          itemExtent: 500,
          itemBuilder: (context, index) {
            // 3. Wrap each item in ScrollSpyItem
            return ScrollSpyItem<int>(
              id: index,
              builder: (context, focus, _) {
                return _InteractiveFeedItem(index: index, focus: focus);
              },
            );
          },
        ),
      ),
    );
  }
}

class _InteractiveFeedItem extends StatelessWidget {
  const _InteractiveFeedItem({required this.index, required this.focus});

  final int index;
  final ScrollSpyItemFocus<int> focus;

  @override
  Widget build(BuildContext context) {
    // React to focus state
    final bool isPrimary = focus.isPrimary;
    final bool isFocused = focus.isFocused;

    // Animate scale based on "how close to center" (focusProgress)
    final double scale = 0.90 + (0.10 * focus.focusProgress);
    final double opacity = 0.4 + (0.6 * focus.focusProgress);

    return Center(
      child: Transform.scale(
        scale: scale,
        child: Opacity(
          opacity: opacity,
          child: Container(
            height: 460,
            margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
            decoration: BoxDecoration(
              color: Colors.grey[900],
              borderRadius: BorderRadius.circular(24),
              boxShadow: isPrimary
                  ? [
                      BoxShadow(
                        color: Colors.indigoAccent.withValues(alpha: 0.4),
                        blurRadius: 30,
                        spreadRadius: 2,
                      ),
                    ]
                  : [],
              border: isPrimary
                  ? Border.all(color: Colors.indigoAccent, width: 2)
                  : Border.all(color: Colors.white10, width: 1),
            ),
            clipBehavior: Clip.antiAlias,
            child: Stack(
              fit: StackFit.expand,
              children: [
                // Placeholder Content (Gradient)
                DecoratedBox(
                  decoration: BoxDecoration(
                    gradient: LinearGradient(
                      begin: Alignment.topLeft,
                      end: Alignment.bottomRight,
                      colors: [
                        Colors
                            .primaries[index % Colors.primaries.length]
                            .shade800,
                        Colors
                            .primaries[(index + 1) % Colors.primaries.length]
                            .shade900,
                      ],
                    ),
                  ),
                ),

                // Content Info
                Positioned(
                  bottom: 30,
                  left: 20,
                  right: 20,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Video Content #${index + 1}',
                        style: const TextStyle(
                          color: Colors.white,
                          fontSize: 24,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                      const SizedBox(height: 12),
                      Row(
                        children: [
                          _StatusBadge(
                            isPrimary: isPrimary,
                            isFocused: isFocused,
                          ),
                          const Spacer(),
                          Text(
                            '${(focus.visibleFraction * 100).toInt()}% visible',
                            style: TextStyle(
                              color: Colors.white.withValues(alpha: 0.7),
                              fontSize: 14,
                            ),
                          ),
                        ],
                      ),
                    ],
                  ),
                ),

                // Play Icon (Centered)
                Center(
                  child: AnimatedContainer(
                    duration: const Duration(milliseconds: 300),
                    width: 72,
                    height: 72,
                    decoration: BoxDecoration(
                      color: isPrimary ? Colors.white : Colors.black45,
                      shape: BoxShape.circle,
                    ),
                    child: Icon(
                      isPrimary ? Icons.pause : Icons.play_arrow,
                      color: isPrimary ? Colors.black : Colors.white,
                      size: 36,
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _StatusBadge extends StatelessWidget {
  const _StatusBadge({required this.isPrimary, required this.isFocused});

  final bool isPrimary;
  final bool isFocused;

  @override
  Widget build(BuildContext context) {
    Color color;
    String label;
    IconData icon;

    if (isPrimary) {
      color = Colors.greenAccent;
      label = 'PLAYING';
      icon = Icons.equalizer;
    } else if (isFocused) {
      color = Colors.amberAccent;
      label = 'READY';
      icon = Icons.hourglass_empty;
    } else {
      color = Colors.grey;
      label = 'PAUSED';
      icon = Icons.pause_circle_outline;
    }

    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
      decoration: BoxDecoration(
        color: color.withValues(alpha: 0.2),
        borderRadius: BorderRadius.circular(20),
        border: Border.all(color: color.withValues(alpha: 0.5)),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(icon, size: 16, color: color),
          const SizedBox(width: 8),
          Text(
            label,
            style: TextStyle(
              color: color,
              fontSize: 13,
              fontWeight: FontWeight.bold,
              letterSpacing: 0.5,
            ),
          ),
        ],
      ),
    );
  }
}
19
likes
0
points
286
downloads

Publisher

verified publishertomars.tech

Weekly Downloads

Compute focused items and a stable primary item in a scrollable viewport (feed/autoplay/analytics).

Repository (GitHub)
View/report issues

Topics

#flutter #scroll #viewport #feed #autoplay

License

unknown (license)

Dependencies

flutter

More

Packages that depend on scroll_spy