Flutter: การใช้งาน ListView เบื้องต้น

ListView เป็นตัว layout ที่ช่วยเอา widgets มาเรียงในแนวตั้งหรือแนวนอนแบบแถวเดียว โดยหากตัว widgets ที่จะเอามาแสดงมีขนาดเกินกว่าพื้นที่ของ Parent มันจะเพิ่ม scroll bar ให้สามารถเลื่อนดูได้ ขนาดของ widgets ที่เอามาแสดงใน ListView ไม่จำเป็นต้องมีขนาดเท่ากันทั้งหมด จริง ๆ มันก็ไม่ต่างจากการใช้แทนการเลื่อนหน้าดูข้อมูลธรรมดา ๆ ที่สามารถใช้งานตัว SingleChildScrollView ที่มี Row หรือ Column เป็นตัวจัด layout ให้

การสร้าง ListView

constructor ที่ใช้สร้าง ListView มีดังนี้

  • ListView เป็นสร้างจากข้อมูล List ตรง ๆ
  • builder สร้างข้อมูลที่แสดงแบบ on demand ผ่าน callback ใน itemBuilder
  • separated คล้ายกับ builder แต่เพิ่มตัวสร้าง widget ที่ใช้สำหรับเป็น "ตัวคั่น" ระหว่าง widget ที่แสดงใน ListView ผ่าน callback ใน separatorBuilder
  • custom ใช้ SliverChildBuilderDelegate ในการสร้าง list

การใช้งาน ListView เบื้องต้น

ListView สามารถแสดงได้ทั้งแนวตั้งและแนวนอน โดยค่าเริ่มต้นคือแนวตั้ง ต้องการให้แสดง widgets อะไรบ้างก็ใส่เข้าไปใน children เหมาะกับการแสดง widget ที่มีจำนวนไม่มาก

ListView({
  Key? key,
  Axis scrollDirection = Axis.vertical,
  List<Widget> children = const <Widget>[],
})

ตัวอย่างแบบง่าย ๆ สร้าง widget ที่มีความสูง 80 และ 100 สำหรับแสดงใน ListView

import 'package:flutter/material.dart';

void main() {
  Widget xWidget(double x) => Container(
      height: x,
      decoration: BoxDecoration(
          color: Colors.green, border: Border.all(color: Colors.yellow)),
      child: Padding(
        padding: const EdgeInsets.all(10.0),
        child: Text("This is xWidget $x"),
      ));

  var listView = ListView(
      children: [xWidget(80), xWidget(100), xWidget(100), xWidget(80)]);

  runApp(MaterialApp(
    home: Scaffold(
      body: listView,
    ),
  ));
}

ตัวอย่างการสร้าง ListView แบบง่าย ๆ

อ่ะ ลองเปลี่ยนเป็นแนวตั้งดู เพิ่ม scrollDirection: Axis.horizontal เหมือนจะง่าย แต่กลายเป็นว่า scroll ไม่ได้ กลายเป็น Row ธรรมดา 😅

var listView = ListView(
      scrollDirection: Axis.horizontal,
      children: [xWidget(80), xWidget(100), xWidget(100), xWidget(80)]);

พอปรับเป็นแนวตั้งบน desktop เลื่อนไม่ได้ซะงั้น

วิธีแก้ไข หากแอปไม่ได้ทำงานบนมือถือพวก Android iOS หรืออุปกรณ์ที่รองรับการสัมผัสด้วยนิ้ว ต้องไปตั้งค่าเพิ่มเติมในส่วนของการใช้ scroll bar

  1. เพิ่มให้รองรับการเลื่อนด้วย mouse ผ่าน PointerDeviceKind.mouse จะสามารถทำให้คลิกค้างเพื่อเอาไว้เลื่อน ListView ได้
  2. ต้องครอบตัว ListView ด้วย ScrollBar และแนบตัว ScrollController เข้าไปเพื่อแสดง scroll bar ในแนวนอน 😑 วิธีนี้จะทำให้มีการแสดงแถบ scroll bar ขึ้นมา และหากต้องการใช้ mouse wheel ต้องกดปุ่ม Shift ที่แป้นพิมพ์ค้างเอาไว้ด้วย
  3. หากต้องการให้ใช้ mouse wheel โดยไม่ต้องการกดปุ่ม Shift ค้างเอาไว้ จำเป็นต้องทำการแปลงค่าการเลื่อนของ mouse wheel จากแกน y เป็นแกน x วิธีการอยู่ใน stackoverflow.com
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

void main() {
  Widget xWidget(double x) => Container(
      height: x,
      decoration: BoxDecoration(
          color: Colors.green, border: Border.all(color: Colors.yellow)),
      child: Padding(
        padding: const EdgeInsets.all(10.0),
        child: Text("This is xWidget $x"),
      ));

  var scrollController = ScrollController(); // for Scrollbar and ListView

  var listView = Scrollbar(
    controller: scrollController,
    child: ListView(
        controller: scrollController,
        scrollDirection: Axis.horizontal,
        children: [xWidget(80), xWidget(100), xWidget(100), xWidget(80)]),
  );

  runApp(MaterialApp(
    scrollBehavior: MaterialScrollBehavior().copyWith(dragDevices: {
      PointerDeviceKind.touch,
      PointerDeviceKind.mouse // add to use drag mouse to scroll
    }),
    home: Scaffold(
      body: listView,
    ),
  ));
}

การสร้าง ListView แบบ on demand

ListView.builder() สำหรับกำหนด callback เพื่อใช้สร้าง widget ที่จะใช้แสดงใน ListView ผ่าน itemBuilder ข้อดีของการสร้างรายการแบบ on demand คือ ประสิทธิภาพของแอปจะดีกว่าแบบที่สร้างทีเดียวแล้วส่งไปให้ ListView เลย ตัว framework จะสร้างแค่พอแสดงบนหน้าจอและเผื่อการเลื่อนหน้าจอขึ้นลงเท่านั้น และยังสามารถใช้งานการรายการที่ไม่ทราบจำนวนล่วงหน้า รวมถึงรายการแบบไม่รู้จบได้อีกด้วย

ตัวอย่างการสร้างรายการเป็นข้อความจำนวน 100 รายการ

import 'package:flutter/material.dart';

void main() {
  var listView = ListView.builder(
    itemCount: 100,
    itemBuilder: (BuildContext context, int index) {
      return Text('item $index');
    },
  );

  runApp(MaterialApp(
    home: Scaffold(
      body: listView,
    ),
  ));
}

การใช้ ListView.builder()

ในการใช้งานทั่วไปสามารถใช้ ListTile เพื่อแสดงข้อความ สี icon และอื่น ๆ เพื่อความสวยงามและประแต่งใช้ในงานทั่ว ๆ ไปได้ทันที

  var listView = ListView.builder(
    itemCount: 100,
    itemBuilder: (BuildContext context, int index) {
      return ListTile(leading: Icon(Icons.info), title: Text("item $index"));
    },
  );

การใช้ ListTile

สร้างตัวแบ่งระหว่างรายการด้วย ListView.separated

การสร้าง widget ที่จะใช้เป็นตัวแบ่ง จะกำหนดใน separatorBuilder ดังตัวอย่างต่อไปนี้จะใช้ Divider เพื่อเป็นเส้นแบ่งระหว่างรายการ โดยรายการที่แสดงต้องมีจำนวนที่แน่นอน นั้นคือต้องระบุ itemCount ด้วยเสมอ

  var listView = ListView.separated(
    itemCount: 100,
    itemBuilder: (BuildContext context, int index) {
      return ListTile(leading: Icon(Icons.info), title: Text("item $index"));
    },
    separatorBuilder: (BuildContext context, int index) {
      return const Divider();
    },
  );

การสร้างตัวแบ่งรายการด้วย Divider()

การสร้างและทำลาย widget ในรายการ

ในการสร้างรายการ widget ที่ใช้แสดงใน ListView แบบ lazily based เช่น การสร้างด้วย ListView.builder() การคงสถานะ หรือ state ของ widget จะสามารถกำหนดได้จากคำสั่ง addAutomaticKeepAlives โดยค่าปริยายจะเป็น true นั้นคือ ทุก widget ที่สร้างขึ้น จะถูกใส่ไว้ใน AutomaticKeepAlive เพื่อแจ้ง framework ให้รู้ว่าให้เก็บค่า State ของ widget เมื่อถูกถอดออกจาก widget tree เมื่อมันไม่ได้แสดงบน ListView แล้ว

Dsmurat, penubag, Jelican9, CC BY-SA 4.0

ในกรณีที่ตัว widget ที่แสดงใน ListView ไม่ได้มีความจำเป็นต้องเก็บ State แนะนำว่าให้กำหนดค่า addAutomaticKeepAlives: false

จำลองการทำงานเมื่อไม่มีการเก็บสถานะ

ตัวอย่าง จะเป็นการสร้าง StatefulWidget ชื่อ ListTileColor ขึ้นมา โดยเมื่อคลิกหรือแตะมัน มันจะทำการสลับสีจาก ขาว แดง เขียว น้ำเงิน วนไปเรื่อย ๆ ค่าสีจะถูกเก็บไว้ใน enum TileColor ที่สร้างขึ้น ตัว ListTileColor จะมี override ตัว initState() และ dispose() เพื่อพิมพ์ข้อความ log เมื่อมีการสร้างและลบ State

import 'package:flutter/material.dart';

void main() {
  var listView = ListView.separated(
    addAutomaticKeepAlives: true,
    itemCount: 100,
    itemBuilder: (BuildContext context, int index) {
      return ListTileColor(index: index); // ListTile for State test
    },
    separatorBuilder: (BuildContext context, int index) {
      return const Divider();
    },
  );

  runApp(MaterialApp(
    home: Scaffold(
      body: listView,
    ),
  ));
}

// color table for Tile ---------------------------------------------
enum TileColor {
  white(Colors.white),
  red(Colors.red),
  green(Colors.green),
  blue(Colors.blue);

  final Color color;

  const TileColor(this.color);
}

// ListTile for State test ------------------------------------------
class ListTileColor extends StatefulWidget {
  final int index; 

  const ListTileColor({super.key, required this.index});

  @override
  State<ListTileColor> createState() => _ListTileColorState();
}

class _ListTileColorState extends State<ListTileColor> {
  late int _tileColor; // index of enum TileColor

  // callback for click or tap the ListTile to change color index
  void _changeColor() => setState(() => _tileColor = (_tileColor + 1) % TileColor.values.length);

  @override
  void initState() {
    super.initState();
    _tileColor = TileColor.white.index;
    log('init state ${widget.index}');
  }

  @override
  void dispose() {
    log('dispose state ${widget.index}');
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _changeColor,
      child: ListTile(
        leading: Icon(Icons.info_outline),
        title: Text("Item index ${widget.index}"),
        tileColor: TileColor.values[_tileColor].color,
      ),
    );
  }
}

ลองทดสอบคลิกเพื่อกำหนดสี แล้วเลื่อนรายการไปมา ผลคือ สีที่ทำไว้หายหมด

สามารถลองเล่นได้ที่ dartpad.dev

เก็บ State เอาไว้ ไม่ dispose ด้วย AutomaticKeepAliveClientMixin

วิธีการให้ตัว ListView คง State เอาไว้แม้ว่าจะถูกเลื่อนหน้าจนหลุดช่วงที่จะใช้งานไปแล้วก็ตาม

  1. ให้กำหนดค่าตอนสร้่าง ListView ด้วย addAutomaticKeepAlives: true ซึ่งค่าปริยายเป็นค่า true อยู่แล้ว ตัว framework จะทำการหุ้มตัว widget ด้วย AutomaticKeepAlive ให้อัตโนมัติ
  2. เพิ่ม AutomaticKeepAliveClientMixin เข้าไปใน State ของ widget ที่ต้องการแสดงใน ListView เพื่อให้ตัว AutomaticKeepAlive ใช้ในการติดต่อกับ widget เกี่ยวกับ State
  3. ทำการ override คำสั่ง bool get wantKeepAlive ถ้าคืนค่าเป็น true จะทำการเก็บค่า State เอาไว้ ถ้าเป็น false จะทำการ dispose เมื่อตัว widget หลุดไปจากหน้าจอ
  4. ในคำสั่ง build() ของ widget จำเป็นต้องเพิ่มคำสั่งเพื่อเรียก super.build() ด้วย ถ้าไม่ใส่ตัว complier จะเตือน

ตัวอย่างส่วนที่แก้ไขเพื่อให้ตัว widget คง State เอาไว้

class _ListTileColorState extends State<ListTileColor> with AutomaticKeepAliveClientMixin {
  // ....
  
  Widget build(BuildContext context) {
    super.build(context);
    // ....
  }

  @override
  bool get wantKeepAlive => true;
}
Dsmurat, penubag, Jelican9, CC BY-SA 4.0

ในการเขียนแอปให้มีประสิทธิภาพ หาก widget ไม่จำเป็นต้องเก็บ State ในขณะใช้งาน ก็ให้คืนค่า wantKeepAlive => false สลับไปมาตามความจำเป็น โดยเมื่อต้องการเปลี่ยนค่านี้ ให้เรียกคำสั่ง updateKeepAlive() ด้วยเสมอ เพื่อให้ตัว framework ดึงค่าล่าสุดไปใช้งาน

ตัวอย่างโปรแกรมที่เพิ่มส่วนของ AutomaticKeepAlive เข้าไป และมีการเลือกที่จะเก็บ State เฉพาะ widget ที่ถูกคลิกเลือกสีอื่นที่ไม่ใช่สีขาวเท่านั้น

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

void main() {
  var listView = ListView.separated(
    addAutomaticKeepAlives: true,
    itemCount: 100,
    itemBuilder: (BuildContext context, int index) {
      return ListTileColor(index: index);
    },
    separatorBuilder: (BuildContext context, int index) {
      return const Divider();
    },
  );

  runApp(MaterialApp(
    home: Scaffold(
      body: listView,
    ),
  ));
}

// color table for Tile ---------------------------------------------
enum TileColor {
  white(Colors.white),
  red(Colors.red),
  green(Colors.green),
  blue(Colors.blue);

  final Color color;

  const TileColor(this.color);
}

// ListTile for State test ------------------------------------------
class ListTileColor extends StatefulWidget {
  final int index;

  const ListTileColor({
    super.key,
    required this.index,
  });

  @override
  State<ListTileColor> createState() => _ListTileColorState();
}

class _ListTileColorState extends State<ListTileColor>
    with AutomaticKeepAliveClientMixin {
  late int _tileColor;
  bool _saveState = false;

  @override
  void initState() {
    super.initState();
    _tileColor = TileColor.white.index;
    log('init state ${widget.index}');
  }

  @override
  void dispose() {
    log('dispose state ${widget.index}');
    super.dispose();
  }

  // callback for click or tap the ListTile to change color index
  void _changeColor() => setState(() {
        _tileColor++;
        if (_tileColor >= TileColor.values.length) {
          //default color index then there is no need to store status values.
          _tileColor = 0;
          _saveState = false;
          updateKeepAlive();
        } else {
          if (!_saveState) {
            _saveState = true;
            updateKeepAlive();
          }
        }
      });

  @override
  Widget build(BuildContext context) {
    super.build(context);

    return GestureDetector(
      onTap: _changeColor,
      child: ListTile(
        leading: Icon(Icons.info_outline),
        title: Text("Item index ${widget.index}"),
        tileColor: TileColor.values[_tileColor].color,
      ),
    );
  }

  @override
  bool get wantKeepAlive => _saveState;
}

ผลการทำงานของ KeepAlive

สามารถลองเล่นได้ที่ dartpad.dev