🍌

スクロールの監視をしてみる

2025/01/15に公開

Flutter Infinite Scroll SNS Demo

スクロールについての機能を仕事で使うことがあったので似たようなロジックを再現してみた。

お題は、シンプルなSNSフィードを模したFlutterアプリケーションで、無限スクロール機能を実装しています。

主な機能

  1. 無限スクロール

    • ScrollControllerを使用してスクロール位置を監視
    • 下端から200ピクセルの位置で新しいデータを読み込み
    • ローディング中の重複読み込みを防止
  2. データ管理

    • 1ページあたり5件の投稿を表示
    • 投稿データはPostモデルクラスで管理
    • ページ番号による投稿の管理
  3. UI機能

    • スクロールバー表示(常時表示)
    • プルトゥリフレッシュ対応
    • ローディング中はCircularProgressIndicatorを表示
    • カード形式での投稿表示

実装の詳細

スクロール制御

void _onScroll() {
  final maxScroll = _scrollController.position.maxScrollExtent;
  final currentScroll = _scrollController.position.pixels;
  final threshold = maxScroll - 200.0;

  if (currentScroll >= threshold && !_isLoading) {
    _loadMorePosts();
  }
}

データ読み込み

  • 初期データ:_loadInitialPosts()で5件読み込み
  • 追加データ:_loadMorePosts()で5件ずつ追加
  • ダミーデータ生成:_generateDummyPosts()で投稿データを生成

投稿表示

  • ユーザーアバター(ダミー画像)
  • ユーザー名
  • タイムスタンプ
  • 投稿内容
  • いいね数

エラー防止機能

  • mountedチェックによるメモリリーク防止
  • ローディング状態管理による重複読み込み防止
  • dispose()でのリソース解放

使用方法

  1. アプリ起動時に最初の5件が表示されます
  2. 下にスクロールすると自動的に次の5件が読み込まれます
  3. 画面を引っ張って更新すると、データがリセットされます
  4. 右側のスクロールバーで現在位置を確認できます

example

こちらがデモです。
https://youtube.com/shorts/ci8A5rQfSpg

値を保持するモデルを作成。

class Post {
  final String id;
  final String username;
  final String content;
  final String timestamp;
  final int likes;
  final String avatarUrl;

  Post({
    required this.id,
    required this.username,
    required this.content,
    required this.timestamp,
    required this.likes,
    required this.avatarUrl,
  });
}

ダミーのデータを表示してスクロールすると画面を更新して新しいデータを取得する。

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

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

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Simple SNS',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        useMaterial3: true,
      ),
      home: const SNSFeedScreen(),
    );
  }
}

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

  
  State<SNSFeedScreen> createState() => _SNSFeedScreenState();
}

class _SNSFeedScreenState extends State<SNSFeedScreen> {
  final ScrollController _scrollController = ScrollController();
  final List<Post> _posts = [];
  bool _isLoading = false;
  int _currentPage = 0;
  static const int _postsPerPage = 5;

  
  void initState() {
    super.initState();
    _loadInitialPosts();
    _scrollController.addListener(_onScroll);
  }

  
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  void _onScroll() {
    final maxScroll = _scrollController.position.maxScrollExtent;
    final currentScroll = _scrollController.position.pixels;
    final threshold = maxScroll - 200.0;

    // debugPrint('Scroll Position: $currentScroll / $maxScroll (Threshold: $threshold)');

    if (currentScroll >= threshold && !_isLoading) {
      // debugPrint('Loading more posts... (Page: ${_currentPage + 1})');
      _loadMorePosts();
    }
  }

  Future<void> _loadInitialPosts() async {
    if (_isLoading) return;

    // debugPrint('Loading initial posts...');
    setState(() {
      _isLoading = true;
      _posts.clear();
    });

    await Future.delayed(const Duration(milliseconds: 500));

    if (mounted) {
      setState(() {
        _posts.addAll(_generateDummyPosts(0, _postsPerPage));
        _isLoading = false;
        _currentPage = 0;
      });
      // debugPrint('Initial posts loaded: ${_posts.length} posts');
    }
  }

  Future<void> _loadMorePosts() async {
    if (_isLoading) return;

    debugPrint('Starting to load more posts...');
    setState(() {
      _isLoading = true;
    });

    await Future.delayed(const Duration(milliseconds: 500));

    if (mounted) {
      final newPosts = _generateDummyPosts(
        (_currentPage + 1) * _postsPerPage,
        _postsPerPage,
      );

      setState(() {
        _posts.addAll(newPosts);
        _currentPage++;
        _isLoading = false;
      });

      debugPrint('Loaded more posts. Total: ${_posts.length} posts');
    }
  }

  List<Post> _generateDummyPosts(int startIndex, int count) {
    final List<String> dummyContents = [
      '今日は素晴らしい一日でした!',
      '新しいプロジェクトを始めました!',
      'フラッターの勉強中です。とても楽しい!',
      '週末は友達と旅行に行きます!',
      'おいしいラーメンを食べました!',
      '新しい技術を学ぶのは楽しいですね',
      'コーディング中です!',
      '今日も一日頑張りました!',
    ];

    return List.generate(count, (index) {
      final actualIndex = startIndex + index;
      return Post(
        id: 'post_$actualIndex',
        username: 'ユーザー${(actualIndex % 10) + 1}',
        content:
            '${dummyContents[actualIndex % dummyContents.length]} (Post #${actualIndex + 1})',
        timestamp: '${actualIndex * 5}分前',
        likes: (actualIndex * 7) % 100 + 1,
        avatarUrl: 'https://i.pravatar.cc/150?img=${(actualIndex % 10) + 1}',
      );
    });
  }

  Future<void> _onRefresh() async {
    // debugPrint('Refreshing feed...');
    await _loadInitialPosts();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Simple SNS'),
        elevation: 1,
      ),
      body: RefreshIndicator(
        onRefresh: _onRefresh,
        child: Scrollbar(
          controller: _scrollController,
          thumbVisibility: true,
          thickness: 8,
          radius: const Radius.circular(4),
          child: ListView.builder(
            controller: _scrollController,
            itemCount: _posts.length + 1,
            itemBuilder: (context, index) {
              if (index == _posts.length) {
                if (_isLoading) {
                  return const Center(
                    child: Padding(
                      padding: EdgeInsets.all(16.0),
                      child: CircularProgressIndicator(),
                    ),
                  );
                } else {
                  return const SizedBox(height: 80);
                }
              }

              final post = _posts[index];
              return Card(
                margin:
                    const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
                child: Padding(
                  padding: const EdgeInsets.all(12.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Row(
                        children: [
                          CircleAvatar(
                            backgroundImage: NetworkImage(post.avatarUrl),
                            radius: 20,
                          ),
                          const SizedBox(width: 12),
                          Expanded(
                            child: Column(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                Text(
                                  post.username,
                                  style: const TextStyle(
                                    fontWeight: FontWeight.bold,
                                    fontSize: 16,
                                  ),
                                ),
                                Text(
                                  post.timestamp,
                                  style: TextStyle(
                                    color: Colors.grey[600],
                                    fontSize: 12,
                                  ),
                                ),
                              ],
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 12),
                      Text(post.content),
                      const SizedBox(height: 8),
                      Row(
                        children: [
                          Icon(Icons.favorite,
                              color: Colors.red[400], size: 20),
                          const SizedBox(width: 4),
                          Text('${post.likes}'),
                        ],
                      ),
                    ],
                  ),
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

まとめ

SNSでよくあるデータを5件とか10件とか取得してスクロールすると次のデータを取得するのに近しい機能を再現してみました。副業でも似たような機能を使ったことありますね。これを使うと必要なデータしか取得しないので、アプリクラッシュするのが避けれる。
一度に何万件もデータ取得するのは危険ですからね😅

Discussion