목차

  1. 사용된 기능 설명
  2. 사용 예시

 

 

 

✅ 사용된 기능 설명

  • ScrollController() : 스크롤 위치를 감시하거나 제어할 때 사용하는 컨트롤러
    • position : 현재 스크롤 상태를 나타내는 ScrollPosition 객체를 반환 (스크롤 위치, 범위 등을 확인 가능)
      • pixels : 현재 스크롤 오프셋 (맨 위 = 0, 아래로 갈수록 값 증가)
        ex) controller.position.pixels == 100.0 → 스크롤이 100px 내려감
      • maxScrollExtent : 스크롤 가능한 최대 위치 (맨 아래)
        ex) position.pixels == maxScrollExtent → 맨 아래 도달
      • minScrollExtent : 스크롤 가능한 최소 위치 (보통 0).
        ex) 스크롤이 맨 위에 있는지 확인할 때 사용
      • extentBefore : 현재 뷰포트(Viewport) 위쪽에 있는 콘텐츠의 길이
        ex) 200px로 내려왔으면 extentBefore = 200
      • extentAfter : 현재 뷰포트 아래쪽에 남아 있는 콘텐츠의 길이.
        ex) 무한 스크롤 로직에서 사용

    • jumpTo(double offset) : 스크롤을 즉시 특정 위치로 이동
    • animateTo(double offset, ...) : 스크롤을 애니메이션과 함께 이동
      ex) 부드럽게 맨 아래로 스크롤
  • addListener(_nextLoad) : ChangeNotifier 또는 Listenable 을 상속한 객체에서 공통적으로 제공되는 메서드. 해당 코드에선 스크롤이 움직일 때마다 _nextLoad 메서드를 실행
  • "$_url?_page=$_page&_limit=$_limit" : 쿼리 파라미터. API 요청할 때 추가 조건(페이지, 개수 등)을 URL 뒤에 붙여 서버로 전달
    ** 실제로 담긴 데이터 "https://jsonplaceholder.typicode.com/albums?_page=1&_limit=20"
  • addAll() : 리스트에 여러 요소를 한 번에 추가. add()는 한 개, addAll()은 여러 개
  • 객체.dispose() : 객체의 메모리 정리 메소드 (페이지를 벗어나면 더 이상 필요 없는 리소스를 해제 해주어 메모리 누수 방지)
  • removeListener() : 컨트롤러는 유지하면서 특정 이벤트만 제거.
  • removeListener(initLoad)를 안하는 이유 : _initLoad는 앱 시작할 때 한 번만 실행되는 함수. 이벤트 리스너가 아니라 단순 메서드 실행이므로 제거할 필요 없음 (_nextLoad는 스크롤 이벤트와 연결된 리스너이므로 제거 필요)
  • 매개변수 내에서 ${} vs 그냥  : 문자열 보간법(${}). 문자열 안에서 변수를 섞어 쓸 때 필요하며 문자열이 아닌 경우는 그냥 바로 넘겨주면 됨
  • if문에서 중괄호 {} 생략 : Dart에서는 if 뒤에 실행할 코드가 하나의 문(statement)이면 {} 생략 가능. 여러 문(statements)이면 반드시 {} 필요.
    ** 중괄호 생략 예시    if(true) Container(...)
    ** 중괄호 사용 예시    if (true) { Container(...); TextButton(...); }

 

 

✅ 사용 예시

 

1️⃣ pubspec.yaml

 

 

2️⃣ main.dart (import)

 

 

3️⃣ main.dart (전역변수 선언 및 초기화, initState)

  1. 전역변수 선언 및 초기화
  2. initState

 

 

4️⃣ main.dart (최초 로딩 시 작동)

 

 

5️⃣ main.dart (다음 페이지 호출 시 작동)

 

 

6️⃣ main.dart (dispose)

 

 

7️⃣ main.dart (Scaffold 작성)

 

 

8️⃣ 테스트

✔️ ios 문제로 http 에서 데이터 안받아와졌는데 갑자기 또 되네 왜지...?

bottmNavigatorBar 추가하면 범위가 침범이 됨

 

 

 

 

✔️ 사용된 코드

import 'package:flutter/material.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const HomePage(),
    );
  }
}

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

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

class _HomePageState extends State<HomePage> {
  final _url = 'https://jsonplaceholder.typicode.com/albums';
  int _page = 1;                      // 초기 시작할 때 페이지 넘버링
  final int _limit = 20;              // 한 번에 가져올 데이터의 양
  bool _hasNextPage = true;           // 다음 페이지 존재 여부 (true : 있다 / false : 없다)
  bool _isFirstLoadRunning = false;   // 첫 번째 로딩 여부 (true : 맞다 / false : 아니다)
  bool _isLoadMoreRunning = false;    // 추가로 더 로딩할 데이터가 있는지 여부 (true : 있다 / false : 없다)
  List _albumList = [];
  late ScrollController _controller;  // 스크롤 위치 확인용

  @override
  void initState(){
    super.initState();  // 부모 초기화 로직을 반드시 실행하도록 보장하는 코드 (항상 첫 줄에서 호출)
    _initLoad();
    // ScrollController() : 스크롤 위치를 감시하거나 제어할 때 사용하는 컨트롤러
    // addListener(_nextLoad) : 스크롤이 움직일 때마다 _nextLoad 메서드를 실행
    _controller = ScrollController()..addListener(_nextLoad);
  }

  void _initLoad() async{    // 최초 로딩 시 작동
    // setState(){ ... } : 문법 오류
    // setState(() { ... }); : setState에 콜백 함수를 넘기는 문법.
    setState((){
      _isFirstLoadRunning = true;   // 최초 로딩이기 때문에 true
    });
    try{
      // "$_url?_page=$_page&_limit=$_limit" : 쿼리 파라미터(Query Parameter). API 요청할 때 **추가 조건(페이지, 개수 등)**을 URL 뒤에 붙여 서버로 전달
      final res = await http.get(Uri.parse("$_url?_page=$_page&_limit=$_limit"));
      setState((){
        _albumList = json.decode(res.body); // url 데이터를 flutter에서 사용가능한 객체(JSON)로 변환
      });
    }catch(e){
      print(e.toString());
    }

    setState((){
      _isFirstLoadRunning = false;  // 최초 로딩이 아니다
    });
  }

  void _nextLoad() async{   // 다음 페이지 호출할 때 작동
    if(_hasNextPage
      && !_isFirstLoadRunning
      && !_isLoadMoreRunning
      && _controller.position.extentAfter < 100 // Scroll 되는 강도 (숫자가 낮을 수록 만감)
    ){  // 다음 페이지가 있고, 초기 로딩이 아니고, 추가로 로딩할 데이터가 없을 때 작동
      setState(() {
        _isLoadMoreRunning = true;
      });
      _page += 1; // 다음 페이지로 넘어갈 때마다 페이지 증가
      try { // http 데이터를 정상적으로 받아와 JSON 데이터로 변환이 됐을 떄
        final res = await http.get(Uri.parse("$_url?_page=$_page&_limit=$_limit"));
        final List fetchedAlbums = json.decode(res.body);
        if(fetchedAlbums.isNotEmpty){ // 데이터가 비어있지 않으면 .addAll()
          setState((){
            _albumList.addAll(fetchedAlbums);   // 리스트에 여러 요소를 한 번에 추가. add()는 한 개, addAll()은 여러 개
          });
        }else{  // 더 이상 데이터가 없으면 마지막 페이지로 초기화 (_hasNextPage = false)
          setState((){
            _hasNextPage = false;
            print("마지막 페이지");   // 더 이상 데이터 없으면 끝, Circle... 안됨
          });
        }
      }catch(e){  // http 데이터를 정상적으로 받아 올 수 없거나, JSON 데이터로 변환에 실패했을 때
        print(e.toString());  // 실제 앱에서는 팝업이나 alert 띄워주기
      }

      setState((){
        _isLoadMoreRunning = false; // 추가로 로딩할 데이터가 없다
      });
    }
  }

  @override
  void dispose() {    // 해당 페이지를 벗어났을 때 연결 해제 (페이지가 여러 개 일 경우 마지막에 필수로 작성)
    // _controller.dispose() : 컨트롤러 자체를 메모리에서 정리
    // removeListener() : 컨트롤러는 유지하면서 특정 이벤트만 제거.
    // 컨트롤러를 계속 쓸 거라면 removeListener()만 사용
    // removeListener(initLoad)를 안하는 이유 : _initLoad는 앱 시작할 때 한 번만 실행되는 함수. 이벤트 리스너가 아니라 단순 메서드 실행이므로 제거할 필요 없음 (_nextLoad는 스크롤 이벤트와 연결된 리스너이므로 제거 필요)
    _controller.removeListener(_nextLoad);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _isFirstLoadRunning ? // 삼항 연산자
      const Center(
        child: CircularProgressIndicator(), // 초기 데이터 호출 시 로딩
      )
      : Column(
        children: [
          Expanded( // 공간을 다 채울 때 사용 (반응형)
            child: ListView.builder(
              controller: _controller,
              itemCount: _albumList.length,
              itemBuilder: (context, index) => Card(
                margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),   // symmetric : 대칭
                child: ListTile(
                  // 매개변수 내에서 ${} vs 그냥 값 : 문자열 보간법(${}). 문자열 안에서 변수를 섞어 쓸 때 필요하며 문자열이 아닌 경우는 그냥 바로 넘겨주면 됨
                  title: Text(_albumList[index]['id'].toString()),
                  subtitle: Text(_albumList[index]['title'].toString()),
                ),
              ),
            ),
          ),
          // if문에서 중괄호 {} 생략 : Dart에서는 if 뒤에 실행할 코드가 하나의 문(statement)이면 {} 생략 가능. 여러 문(statements)이면 반드시 {} 필요.
          if(_isLoadMoreRunning)
            Container(
              padding: const EdgeInsets.all(30),
              child: const Center(
                child: CircularProgressIndicator(),
              ),
            ),

          if(_hasNextPage == false)
            Container(
              padding: const EdgeInsets.all(20),
              color: Colors.blue,
              child: const Center(
                child: Text(
                  "No more data to be fetched",
                  style: TextStyle(color: Colors.white),
                ),
              ),
            )
        ],
      ),
    );
  }
}

+ Recent posts