scrolled_pagination 1.1.0 copy "scrolled_pagination: ^1.1.0" to clipboard
scrolled_pagination: ^1.1.0 copied to clipboard

A reusable, MVC-style infinite-scroll pagination toolkit for Flutter — one controller, many providers (Future/Stream/sync), page or cursor based, with seven builders, item ops, scroll actions and an o [...]

example/lib/main.dart

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

import 'chrome.dart';
import 'code_reference.dart';
import 'data.dart';
import 'frames.dart';
import 'theme.dart';
import 'widgets.dart';

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

class DemoApp extends StatelessWidget {
  const DemoApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'scrolled_pagination',
      debugShowCheckedModeBanner: false,
      theme: CA.theme(),
      home: const Workbench(),
    );
  }
}

class Workbench extends StatefulWidget {
  const Workbench({super.key});
  @override
  State<Workbench> createState() => _WorkbenchState();
}

class _WorkbenchState extends State<Workbench> {
  String dataset = 'feed';
  String mode = 'page';
  String kind = 'future';
  String layout = 'list';
  bool emptyOn = false;
  String lastEvent = '—';

  static const double _canvasWidth = 1460;

  late PaginationController<Object> _controller = _make();

  PaginationController<Object> _make() => PaginationController<Object>(
        pageSize: 12,
        initialPageParam: mode == 'cursor' ? null : 1,
        getKey: (o) => o is Post ? o.id : (o as Member).id,
        openFocusManager: true,
        provider: makeProvider(dataset: dataset, mode: mode, kind: kind),
        onItemInserted: (i, _) => _log('onItemInserted #$i'),
        onItemUpdated: (i, _) => _log('onItemUpdated #$i'),
        onItemDeleted: (i, _) => _log('onItemDeleted #$i'),
        onItemReplaced: (i, _) => _log('onItemReplaced #$i'),
        onItemMoved: (a, b) => _log('onItemMoved $a→$b'),
        onFocusChanged: (i, _) => _log('onFocusChanged #$i'),
        onJumbFailed: (f) => _log('onJumbFailed: ${f.reason}'),
        onIndexOutOfRange: (i) => _log('onIndexOutOfRange #$i'),
      );

  void _log(String e) => setState(() => lastEvent = e);

  void _reconfigure() {
    final old = _controller;
    _controller = _make();
    setState(() {});
    WidgetsBinding.instance.addPostFrameCallback((_) => old.dispose());
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Object? get _first => _controller.items.isEmpty ? null : _controller.items.first;

  Object _synthetic({String? title}) {
    final id = DateTime.now().microsecondsSinceEpoch;
    if (dataset == 'members') {
      return Member(id: id, name: 'New Member', role: 'Member', club: 'Madina IT', online: true, tasks: 0, since: 2026);
    }
    return Post(
      id: id,
      kind: 'news',
      club: 'Madina IT',
      author: 'You',
      when: 'Just now',
      title: title ?? 'New post added via controller',
      excerpt: 'This item was inserted without rebuilding the rest of the list.',
      likes: 0,
      comments: 0,
    );
  }

  PaginationLayout get _layout => switch (layout) {
        'grid' => PaginationLayout.grid,
        'separated' => PaginationLayout.separated,
        _ => PaginationLayout.list,
      };
  bool get _reverse => layout == 'reverse';
  bool get _grid => layout == 'grid';

  // --- item builders ----------------------------------------------------------
  Widget _phoneItem(BuildContext context, Object item, int index) {
    if (item is Member) return _grid ? MemberCardW(member: item) : MemberRowW(member: item);
    final p = item as Post;
    return _grid ? FeedCardV(post: p) : FeedCardH(post: p);
  }

  Widget _browserItem(BuildContext context, Object item, int index) {
    if (item is Member) return _grid ? MemberCardW(member: item) : MemberRowW(member: item);
    final p = item as Post;
    return _grid ? FeedCardV(post: p) : FeedRowWide(post: p);
  }

  // --- shared builder slots ---------------------------------------------------
  Widget _firstLoading(BuildContext _) => SkeletonList(grid: _grid);
  Widget _empty(BuildContext _, Future<void> Function() refresh) => _Empty(onRefresh: refresh, dataset: dataset);
  Widget _error(BuildContext _, Object error, Future<void> Function() retry) => _Error(error: error, onRetry: retry);

  PaginatedScrollView<Object> _view({
    required String tag,
    required PaginatedItemBuilder<Object> itemBuilder,
    required EdgeInsets padding,
    required double gap,
    required int crossAxisCount,
    required double childAspectRatio,
    required bool pullToRefresh,
  }) {
    return PaginatedScrollView<Object>(
      key: ValueKey('$tag|$dataset|$mode|$kind|$layout'),
      controller: _controller,
      layout: _layout,
      reverse: _reverse,
      loadMoreThreshold: 280,
      padding: padding,
      gap: gap,
      crossAxisCount: crossAxisCount,
      childAspectRatio: childAspectRatio,
      pullToRefresh: pullToRefresh,
      itemBuilder: itemBuilder,
      separatorBuilder: (_, __) => const Divider(height: 20, color: CA.ink100),
      firstPageLoadingBuilder: _firstLoading,
      loadMoreBuilder: (_) => const _LoadMore(),
      noMoreBuilder: (_) => const _NoMore(),
      emptyBuilder: _empty,
      errorBuilder: _error,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: CA.canvas,
      body: SafeArea(
        child: SingleChildScrollView(
          child: SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Padding(
              padding: const EdgeInsets.fromLTRB(24, 20, 24, 32),
              child: SizedBox(
                width: _canvasWidth,
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    _header(),
                    const SizedBox(height: 16),
                    _controls(),
                    const SizedBox(height: 16),
                    _actions(),
                    const SizedBox(height: 18),
                    _stage(),
                    const SizedBox(height: 16),
                    const StatesLegend(),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }

  // --- header -----------------------------------------------------------------
  Widget _header() => Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            width: 46,
            height: 46,
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(13),
              gradient: const LinearGradient(
                  colors: [CA.violet, CA.violet700],
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight),
            ),
            alignment: Alignment.center,
            child: const Text('SP', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 18)),
          ),
          const SizedBox(width: 14),
          const Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: [
                Text('Scrolled Pagination',
                    style: TextStyle(fontSize: 23, fontWeight: FontWeight.w700, color: CA.ink900)),
                SizedBox(height: 2),
                Text('One controller · many providers · seven builders — a reusable infinite-scroll component for ClubApp',
                    style: TextStyle(fontSize: 13, color: CA.ink500)),
              ],
            ),
          ),
          const SizedBox(width: 24),
          StatusPill(controller: _controller, lastEvent: lastEvent),
        ],
      );

  // --- segmented controls -----------------------------------------------------
  Widget _divider() => Container(width: 1, color: CA.ink100, margin: const EdgeInsets.symmetric(vertical: 2));

  Widget _controls() => Container(
        padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
        decoration: BoxDecoration(
          color: CA.surface,
          borderRadius: BorderRadius.circular(16),
          boxShadow: CA.cardShadow,
        ),
        child: IntrinsicHeight(
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Segmented<String>(
                label: 'Data source',
                value: dataset,
                options: const [(value: 'feed', label: 'Feed posts'), (value: 'members', label: 'Members')],
                onChanged: (v) { dataset = v; _reconfigure(); },
              ),
              const SizedBox(width: 16),
              _divider(),
              const SizedBox(width: 16),
              Segmented<String>(
                label: 'Pagination',
                value: mode,
                options: const [(value: 'page', label: 'Page-based'), (value: 'cursor', label: 'Cursor-based')],
                onChanged: (v) { mode = v; _reconfigure(); },
              ),
              const SizedBox(width: 16),
              _divider(),
              const SizedBox(width: 16),
              Segmented<String>(
                label: 'Provider',
                value: kind,
                options: const [(value: 'future', label: 'Future'), (value: 'stream', label: 'Stream'), (value: 'sync', label: 'Sync')],
                onChanged: (v) { kind = v; _reconfigure(); },
              ),
              const SizedBox(width: 16),
              _divider(),
              const SizedBox(width: 16),
              Segmented<String>(
                label: 'Layout',
                value: layout,
                options: const [(value: 'list', label: 'List'), (value: 'grid', label: 'Grid'), (value: 'separated', label: 'Separated'), (value: 'reverse', label: 'Reverse')],
                onChanged: (v) => setState(() => layout = v),
              ),
            ],
          ),
        ),
      );

  // --- action rows ------------------------------------------------------------
  Widget _actions() => Column(
        children: [
          ActionRow(label: 'Actions', children: [
            ActionButton('Refresh', tone: ActTone.accent, icon: Icons.refresh, onTap: _controller.refresh),
            ActionButton('Load more', onTap: _controller.loadMore),
            ActionButton('+ Add', onTap: () => _controller.addItem(_synthetic(), prepend: true)),
            ActionButton('Update first', onTap: _updateFirst),
            ActionButton('– Remove first', onTap: () {
              final f = _first;
              if (f != null) _controller.removeItem(f is Post ? f.id : (f as Member).id);
            }),
            ActionButton('Clear', onTap: _controller.clear),
            ActionButton('Force error', tone: ActTone.danger, onTap: () {
              Sim.failRate = 1;
              _controller.refresh().whenComplete(() => Sim.failRate = 0);
            }),
            ActionButton(emptyOn ? 'Empty: on' : 'Empty state', tone: emptyOn ? ActTone.danger : ActTone.normal, onTap: () {
              setState(() => emptyOn = !emptyOn);
              Sim.emptyMode = emptyOn;
              _controller.refresh();
            }),
          ]),
          ActionRow(label: 'Where & move', children: [
            ActionButton('insertItem @2', onTap: () => _controller.insertItem(2, _synthetic())),
            ActionButton('insertItems ×3 @0', onTap: () => _controller.insertItems(0, [_synthetic(), _synthetic(), _synthetic()])),
            ActionButton('updateWhere', onTap: _updateWhere),
            ActionButton('deleteWhere', tone: ActTone.danger, onTap: _deleteWhere),
            ActionButton('moveItem 0→5', onTap: () => _controller.moveItem(0, 5)),
            ActionButton('jumbTo top', icon: Icons.refresh, onTap: () => _controller.jumbTo(0)),
            ActionButton('moveTo last', tone: ActTone.accent, onTap: () => _controller.moveTo(_controller.count - 1)),
          ]),
          ActionRow(label: 'Replace & focus', children: [
            ActionButton('replaceAt @1', onTap: () {
              if (_controller.count > 1) _controller.replaceAt(1, _synthetic(title: 'Replaced via replaceAt(1)'));
            }),
            ActionButton('swapItems 0↔3', onTap: () => _controller.swapItems(0, 3)),
            ActionButton('moveToLast 0', onTap: () => _controller.moveToLast(0)),
            ActionButton('jumbToLast', onTap: () => _controller.jumbToLast()),
            ActionButton('animateToFirst', onTap: () => _controller.animateToFirst()),
            ActionButton('ensureVisibleAt 8', onTap: () => _controller.ensureVisibleAt(8, options: const ScrollOptions(alignment: 0.5))),
            ActionButton('‹ focusPrev', onTap: () => _controller.focusPrevious()),
            ActionButton('focusNext ›', tone: ActTone.accent, onTap: () => _controller.focusNext()),
            ActionButton('clearFocus', onTap: _controller.clearFocus),
          ]),
        ],
      );

  void _updateFirst() {
    final f = _first;
    if (f is Post) {
      _controller.updateItem(f.id, (o) {
        final p = o as Post;
        return p.copyWith(liked: !p.liked, likes: p.liked ? p.likes - 1 : p.likes + 1);
      });
    } else if (f is Member) {
      _controller.updateItem(f.id, (o) {
        final m = o as Member;
        return m.copyWith(online: !m.online);
      });
    }
  }

  void _updateWhere() {
    if (dataset == 'members') {
      _controller.updateWhere(
        (o) => o is Member && o.role == 'Member',
        (o) {
          final m = o as Member;
          return m.copyWith(role: 'Moderator');
        },
      );
    } else {
      _controller.updateWhere(
        (o) => o is Post && o.club == 'Madina IT',
        (o) {
          final p = o as Post;
          return p.copyWith(liked: true, likes: p.likes + 1);
        },
      );
    }
  }

  void _deleteWhere() {
    if (dataset == 'members') {
      _controller.deleteWhere((o) => o is Member && !o.online);
    } else {
      _controller.deleteWhere((o) => o is Post && o.kind == 'article');
    }
  }

  // --- stage: phone + browser + code reference --------------------------------
  Widget _stage() {
    final feed = dataset != 'members';
    final phoneTitle = feed ? 'News & articles' : 'Members';
    final phoneSub = '${mode == 'cursor' ? 'cursor-based' : 'page-based'} · $kind';
    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        PhoneFrame(
          title: phoneTitle,
          subtitle: phoneSub,
          child: _view(
            tag: 'phone',
            itemBuilder: _phoneItem,
            padding: const EdgeInsets.all(14),
            gap: 12,
            crossAxisCount: 2,
            childAspectRatio: feed ? 0.74 : 0.85,
            pullToRefresh: true,
          ),
        ),
        const SizedBox(width: 22),
        Expanded(
          child: Column(
            children: [
              BrowserWindow(
                url: 'app.clubapp.com / ${feed ? 'feed' : 'members'}',
                height: 560,
                child: _view(
                  tag: 'browser',
                  itemBuilder: _browserItem,
                  padding: feed
                      ? const EdgeInsets.symmetric(horizontal: 20)
                      : const EdgeInsets.all(16),
                  gap: feed ? 0 : 12,
                  crossAxisCount: 3,
                  childAspectRatio: feed ? 0.9 : 1.0,
                  pullToRefresh: false,
                ),
              ),
              const SizedBox(height: 18),
              const CodeReferencePanel(height: 250),
            ],
          ),
        ),
      ],
    );
  }
}

// ---------------------------------------------------------------------------
// Footer / empty / error builders (ClubApp-styled)
// ---------------------------------------------------------------------------
class _LoadMore extends StatelessWidget {
  const _LoadMore();
  @override
  Widget build(BuildContext context) => const Padding(
        padding: EdgeInsets.all(18),
        child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
          SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2.4, color: CA.violet)),
          SizedBox(width: 10),
          Text('Loading more…', style: TextStyle(fontSize: 12, color: CA.ink500)),
        ]),
      );
}

class _NoMore extends StatelessWidget {
  const _NoMore();
  @override
  Widget build(BuildContext context) => Padding(
        padding: const EdgeInsets.all(18),
        child: Row(children: const [
          Expanded(child: Divider(color: CA.ink100)),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 10),
            child: Row(mainAxisSize: MainAxisSize.min, children: [
              Icon(Icons.check_rounded, size: 14, color: CA.success),
              SizedBox(width: 6),
              Text('You are all caught up', style: TextStyle(fontSize: 11, color: CA.ink400)),
            ]),
          ),
          Expanded(child: Divider(color: CA.ink100)),
        ]),
      );
}

class _Empty extends StatelessWidget {
  const _Empty({required this.onRefresh, required this.dataset});
  final Future<void> Function() onRefresh;
  final String dataset;
  @override
  Widget build(BuildContext context) {
    final label = dataset == 'members' ? 'members' : 'posts';
    return Center(
      child: Column(mainAxisSize: MainAxisSize.min, children: [
        Container(
          width: 92,
          height: 92,
          decoration: BoxDecoration(color: CA.violet.withOpacity(0.08), borderRadius: BorderRadius.circular(26)),
          alignment: Alignment.center,
          child: Container(
            width: 54,
            height: 54,
            decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(18), boxShadow: CA.cardShadow),
            child: const Icon(Icons.inbox_outlined, color: CA.violet),
          ),
        ),
        const SizedBox(height: 16),
        Text('No $label found yet', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: CA.ink900)),
        const SizedBox(height: 6),
        SizedBox(
          width: 240,
          child: Text('Looks like you do not have any $label yet. You can refresh anytime.',
              textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: CA.ink500)),
        ),
        const SizedBox(height: 16),
        FilledButton(style: FilledButton.styleFrom(backgroundColor: CA.violet), onPressed: onRefresh, child: const Text('Refresh')),
      ]),
    );
  }
}

class _Error extends StatelessWidget {
  const _Error({required this.error, required this.onRetry});
  final Object error;
  final Future<void> Function() onRetry;
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(mainAxisSize: MainAxisSize.min, children: [
        Container(
          width: 72,
          height: 72,
          decoration: BoxDecoration(color: CA.danger.withOpacity(0.1), borderRadius: BorderRadius.circular(22)),
          child: const Icon(Icons.wifi_off_rounded, color: CA.danger, size: 28),
        ),
        const SizedBox(height: 16),
        const Text('Something went wrong', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: CA.ink900)),
        const SizedBox(height: 6),
        SizedBox(
          width: 260,
          child: Text('$error'.replaceAll('Exception: ', ''), textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: CA.ink500)),
        ),
        const SizedBox(height: 16),
        FilledButton(style: FilledButton.styleFrom(backgroundColor: CA.violet), onPressed: onRetry, child: const Text('Retry')),
      ]),
    );
  }
}
1
likes
0
points
46
downloads

Documentation

Documentation

Publisher

unverified uploader

Weekly Downloads

A reusable, MVC-style infinite-scroll pagination toolkit for Flutter — one controller, many providers (Future/Stream/sync), page or cursor based, with seven builders, item ops, scroll actions and an optional focus manager.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter

More

Packages that depend on scrolled_pagination