Skip to main content

pusk_list 0.1.1 copy "pusk_list: ^0.1.1" to clipboard
pusk_list: ^0.1.1 copied to clipboard

Flutter list with key-based scroll anchoring, jumps to any item and built-in insert/remove animations.

example/lib/main.dart

import 'dart:async';
import 'dart:math';

import 'package:example/chat_data.dart';
import 'package:example/chat_scope.dart';
import 'package:example/message_tile.dart';
import 'package:example/perf_hud.dart';
import 'package:flutter/material.dart';
import 'package:pusk_list/pusk_list.dart';

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

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

  @override
  Widget build(BuildContext context) {
    ThemeData theme(Brightness brightness) => ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: const Color(0xFF5B5BD6),
        brightness: brightness,
      ),
    );
    return MaterialApp(
      title: 'pusk_list',
      debugShowCheckedModeBanner: false,
      theme: theme(Brightness.light),
      darkTheme: theme(Brightness.dark),
      home: const ChatScreen(),
    );
  }
}

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

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final _generator = ChatGenerator();
  late final _chat = ChatModel(_generator.older(1000));
  final _controller = PuskListController();
  final _input = TextEditingController();
  final _random = Random(3);

  final ValueNotifier<int> _unread = ValueNotifier(0);
  FrameStats? _stats;
  final _timers = <Timer>{};

  @override
  void initState() {
    super.initState();
    _controller.visibleRange.addListener(_onVisibleRangeChanged);
  }

  @override
  void dispose() {
    for (final timer in _timers) {
      timer.cancel();
    }
    _controller.visibleRange.removeListener(_onVisibleRangeChanged);
    _controller.dispose();
    _chat.dispose();
    _unread.dispose();
    _input.dispose();
    _stats?.dispose();
    super.dispose();
  }

  bool get _atLatest => (_controller.visibleRange.value?.first ?? 0) == 0;

  void _onVisibleRangeChanged() {
    if (_atLatest) _unread.value = 0;
  }

  void _later(Duration delay, VoidCallback callback) {
    late final Timer timer;
    timer = Timer(delay, () {
      _timers.remove(timer);
      if (mounted) callback();
    });
    _timers.add(timer);
  }

  void _receive(Message message) {
    _chat.add(message);
    if (!_atLatest) _unread.value++;
  }

  void _send() {
    final text = _input.text.trim();
    if (text.isEmpty) return;
    _input.clear();
    _chat.add(_generator.mine(text));
    if (!_atLatest) _scrollToLatest();
  }

  /// Many incoming messages in quick succession.
  void _burst() {
    for (var i = 0; i < 20; i++) {
      _later(Duration(milliseconds: 120 * i), () {
        _receive(_generator.incoming());
      });
    }
  }

  void _toggleStats() {
    setState(() {
      _stats?.dispose();
      _stats = _stats == null ? FrameStats() : null;
    });
  }

  void _scrollToLatest() {
    unawaited(
      _controller.animateToIndex(
        0,
        duration: const Duration(milliseconds: 450),
        curve: Curves.easeOutCubic,
      ),
    );
  }

  /// Animates to a message far away, through items that were never laid
  /// out, and highlights it.
  Future<void> _jumpToRandom() async {
    final messages = _chat.messages;
    final message = messages[_random.nextInt(messages.length)];
    await _controller.animateToKey(
      message,
      alignment: 0.5,
      duration: const Duration(milliseconds: 700),
      curve: Curves.easeInOutCubic,
    );
    final highlighted = _chat.highlighted..value = message.id;
    _later(const Duration(milliseconds: 1200), () {
      if (highlighted.value == message.id) highlighted.value = null;
    });
  }

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return ChatScope(
      chat: _chat,
      controller: _controller,
      child: Scaffold(
        backgroundColor: scheme.surfaceContainerLowest,
        appBar: _ChatAppBar(
          onJump: _jumpToRandom,
          onBurst: _burst,
          statsVisible: _stats != null,
          onToggleStats: _toggleStats,
        ),
        body: Column(
          children: [
            Expanded(
              child: Stack(
                children: [
                  const _ChatList(),
                  if (_stats case final stats?)
                    Positioned(top: 8, right: 8, child: PerfHud(stats: stats)),
                  Positioned(
                    right: 12,
                    bottom: 12,
                    // Listens by itself: scrolling doesn't rebuild the list.
                    child: ListenableBuilder(
                      listenable: Listenable.merge([
                        _controller.visibleRange,
                        _unread,
                      ]),
                      builder: (context, _) => _ScrollToLatestButton(
                        visible: !_atLatest,
                        unread: _unread.value,
                        onPressed: _scrollToLatest,
                      ),
                    ),
                  ),
                ],
              ),
            ),
            _Composer(controller: _input, onSend: _send),
          ],
        ),
      ),
    );
  }
}

class _ChatAppBar extends StatelessWidget implements PreferredSizeWidget {
  const _ChatAppBar({
    required this.onJump,
    required this.onBurst,
    required this.statsVisible,
    required this.onToggleStats,
  });

  final VoidCallback onJump;
  final VoidCallback onBurst;
  final bool statsVisible;
  final VoidCallback onToggleStats;

  @override
  Size get preferredSize => const Size.fromHeight(kToolbarHeight);

  @override
  Widget build(BuildContext context) {
    return AppBar(
      backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
      titleSpacing: 0,
      leading: const Padding(
        padding: EdgeInsets.all(10),
        child: CircleAvatar(child: Icon(Icons.forum_rounded, size: 20)),
      ),
      title: const Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [Text('Team chat'), _MessageCount()],
      ),
      actions: [
        IconButton(
          tooltip: 'Jump to a random message',
          icon: const Icon(Icons.shuffle_rounded),
          onPressed: onJump,
        ),
        PopupMenuButton<VoidCallback>(
          onSelected: (action) => action(),
          itemBuilder: (context) => [
            PopupMenuItem(
              value: onBurst,
              child: const ListTile(
                leading: Icon(Icons.bolt_rounded),
                title: Text('Burst of 20 messages'),
              ),
            ),
            const PopupMenuDivider(),
            CheckedPopupMenuItem(
              value: onToggleStats,
              checked: statsVisible,
              child: const Text('Frame stats'),
            ),
          ],
        ),
      ],
    );
  }
}

class _MessageCount extends StatelessWidget {
  const _MessageCount();

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Text(
      '${ChatScope.of(context).chat.messages.length} messages',
      style: theme.textTheme.bodySmall?.copyWith(
        color: theme.colorScheme.onSurfaceVariant,
      ),
    );
  }
}

class _ChatList extends StatelessWidget {
  const _ChatList();

  @override
  Widget build(BuildContext context) {
    final scope = ChatScope.of(context);
    final chat = scope.chat;
    final messages = chat.messages;
    return PuskList.separated(
      controller: scope.controller,
      reverse: true,
      padding: const EdgeInsets.symmetric(vertical: 12),
      physics: const AlwaysScrollableScrollPhysics(),
      keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
      itemCount: messages.length,
      itemKey: (index) => messages[index],
      estimatedExtent: 64,
      transition: const PuskItemTransition.sizeFade(),
      itemBuilder: (context, index) {
        final message = messages[index];
        return MessageTile(
          message: message,
          highlighted: chat.highlighted,
          onLongPress: () => chat.remove(message),
        );
      },
      separatorBuilder: (context, index) => MessageSeparator(
        newer: messages[index],
        older: messages[index + 1],
      ),
    );
  }
}

class _ScrollToLatestButton extends StatelessWidget {
  const _ScrollToLatestButton({
    required this.visible,
    required this.unread,
    required this.onPressed,
  });

  final bool visible;
  final int unread;
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: visible ? 1 : 0,
      duration: const Duration(milliseconds: 200),
      curve: Curves.easeOutBack,
      child: Badge(
        isLabelVisible: unread > 0,
        label: Text('$unread'),
        child: FloatingActionButton.small(
          heroTag: null,
          onPressed: onPressed,
          child: const Icon(Icons.keyboard_arrow_down_rounded),
        ),
      ),
    );
  }
}

class _Composer extends StatelessWidget {
  const _Composer({required this.controller, required this.onSend});

  final TextEditingController controller;
  final VoidCallback onSend;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Material(
      color: scheme.surfaceContainer,
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              Expanded(
                child: TextField(
                  controller: controller,
                  minLines: 1,
                  maxLines: 5,
                  textCapitalization: TextCapitalization.sentences,
                  onSubmitted: (_) => onSend(),
                  decoration: InputDecoration(
                    hintText: 'Message',
                    filled: true,
                    fillColor: scheme.surfaceContainerHighest,
                    contentPadding: const EdgeInsets.symmetric(
                      horizontal: 16,
                      vertical: 10,
                    ),
                    border: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(24),
                      borderSide: BorderSide.none,
                    ),
                  ),
                ),
              ),
              const SizedBox(width: 6),
              IconButton.filled(
                onPressed: onSend,
                icon: const Icon(Icons.send_rounded),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
0
likes
160
points
105
downloads

Documentation

API reference

Publisher

verified publisherpusk.software

Weekly Downloads

Flutter list with key-based scroll anchoring, jumps to any item and built-in insert/remove animations.

Repository (GitHub)
View/report issues

Topics

#list #scroll #sliver #animation #chat

License

MIT (license)

Dependencies

flutter

More

Packages that depend on pusk_list