FlutterでListViewしたい
こんにちは。
陰キャな仮想通貨投資家こと、梅木栽培マンです。
ReactNativeに辟易したので、最近はFlutterを触っています。
スマホアプリといえばListViewですよね。
FlutterでListViewしようとしたときに出てきた疑問と解の雑メモです。
Contents
単純なListViewしたい
ドキュメントを読みましょう。
https://docs.flutter.io/flutter/widgets/ListView-class.html
There are three options for constructing a ListView:
The default constructor takes an explicit List of children. This constructor is appropriate for list views with a small number of children because constructing the List requires doing work for every child that could possibly be displayed in the list view instead of just those children that are actually visible.
The ListView.builder takes an IndexedWidgetBuilder, which builds the children on demand. This constructor is appropriate for list views with a large (or infinite) number of children because the builder is called only for those children that are actually visible.
The ListView.custom takes a SliverChildDelegate, which provides the ability to customize additional aspects of the child model. For example, a SliverChildDelegate can control the algorithm used to estimate the size of children that are not actually visible.
つまり、ListView.builderを使いましょうと書いていますね()
『デフォルトのコンストラクタだと子Viewを全部描画しようとするんで、表示する要素数が少ないときに使うといいすよ。』と言ってます。
ドロワーに表示するメニュー一覧とか、ある程度要素数の規模が読めそうなモノに使うのが良さげですね。
一方、ツイートだのフィードだの写真のサムネ一覧みたいな表示しようと思ったらいくらでも出てきちゃうような大量の要素をListView上に展開したい場合は避けたほうがいいでしょう。
『上記のようなケースではListView.builderを使いましょう。』と言ってます。
ListView.customはちょっと何言ってるか分からないです。
ListView.builderはよくあるスマホアプリを作ろうとすると必須なモノなので、これだけ学びます。
ListView.builderの使い方はザックリ下記の通り。
1 2 3 4 5 6 7 8 |
new ListView.builder( itemCount: this._objectList.length, itemBuilder: (BuildContext context, int index) { Clip clip = this._objectList[index]; return new Card( child: new Padding(padding: new EdgeInsets.all(16.0), child: new Image.network(clip.imageUrl())) ); }) |
itemBuilderにWidgetを返す関数を渡してやる必要がある。これだけ覚えておけば君はすでにListViewだ。
itemCountはListViewで表示する要素の数。
これを明示的に指定しないと無限なListViewができる。
上の例では予め与えられた_objectListというヤツの要素数を指定してる。
他にもpaddingとかitemExtentとかあるので、興味があればドキュメントを読みましょう。
http通信で取得したJSONデータを使ってListViewしたい
書き方はイケてないけど、ヤリたいこと実現するコードはこんな感じですね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
class _HomePageState extends State<HomePage> { List<Clip> _objectList = []; bool _isError = false; String _errorMessage = ""; @override void initState() { super.initState(); this._loadClips(); } @override Widget build(BuildContext context) { if (this._isError) { return new Center(child: new Text(this._errorMessage),); } return new ListView.builder( padding: new EdgeInsets.all(4.0), itemCount: this._objectList.length, itemExtent: 300.0, itemBuilder: (BuildContext context, int index) { Clip clip = this._objectList[index]; return new Card( child: new Padding(padding: new EdgeInsets.all(16.0), child: new Image.network(clip.imageUrl())) ); }); } void _loadClips() { new AwesomeApi().getClipList().then((x) { setState(() { this._isError = false; this._objectList.insertAll(this._objectList.length, x); }); }).catchError((e) { setState(() { this._isError = true; this._errorMessage = e.toString(); }); }); } } class AwesomeApi { final httpClient = new HttpClient(); Future <List<Clip>> getClipList({int offset: 0}) async { var uri = new Uri.https('awesome.hogehuga.com', '/api/clips', {"offset": offset.toString()}); try { var request = await httpClient.getUrl(uri); var response = await request.close(); var responseBody = await response.transform(UTF8.decoder).join(); var json = JSON.decode(responseBody); var dataList = json['results']; return dataList.map((m) => new Clip.fromJson(m)).toList(); } catch (e) { throw new Exception(e.toString()); } } } class Clip { String id; String imageUrl; Clip(this.id, this.imageUrl); Clip.fromJson(Map<String, dynamic> json) : id = json['id'].toString(), imageUrl = json['huku']; String imageUrl() { return "https://assets.hogehuga.com/thumbnails/" + this.imageUrl; } } |
読めば何したいかなんとなく分かるかと思います(たぶん)。
initState時にClipオブジェクトを取得する処理(_loadClipってヤツね)を呼び出して、setStateで状態を更新してるだけですね。
Flutterで採用されてるDartのことはあんまり知りませんが、シングルスレッドらしいです。
非同期処理はFutureなりStreamsを使いましょう。
今回はhttp通信でJSONデータを取得したいんでFutureを使ってます。(キーボードの入力イベントとかはStreamsかな?たぶん)
非同期処理しようとすると『async』と『await』は必ず使うことになるので、どういうモノかドキュメント読むなりして理解しておくと良さそうです。
FutureのエラーハンドリングはcatchError
ってヤツでデキます。
- FutureBuilderというWidgetを使うと捗るかもしれませんね
- https://stackoverflow.com/questions/44645260/how-to-implement-api-calls-in-flutter
- https://www.youtube.com/watch?v=R2I0osLdjgQ
ページング可能なAPIを使って無限スクロールするListViewしたい
この要件もスマホアプリあるある言いたいですね。
googlingしても見つけられなかったので書いておきます。
前提としてAPI側でページングできるようにしておく必要あります。
その上で下記のようなことをすればinfinite scroll listview in flutterできますね。
- 『スクロール位置がListViewの底まで来た』を検知できるようにする
- _loadClipにoffsetを渡して、次に追加すべきClipのリストをゲットする
- setStateで状態を更新
コードはこんな感じ。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
class _HomePageState extends State<HomePage> { List<Clip> _objectList = []; bool _isLoading = false; bool _isError = false; String _errorMessage = ""; @override void initState() { super.initState(); this._loadClips(0); } @override Widget build(BuildContext context) { if (this._isError) { return new Center(child: new Text(this._errorMessage),); } return new NotificationListener<ScrollNotification>( onNotification: (ScrollNotification value) { if (value.metrics.extentAfter == 0.0) { this._loadClips(this._objectList.length); } }, child: new ListView.builder( padding: new EdgeInsets.all(4.0), itemCount: this._objectList.length, itemExtent: 300.0, itemBuilder: (BuildContext context, int index) { Clip clip = this._objectList[index]; return new Card( child: new Padding(padding: new EdgeInsets.all(16.0), child: new Image.network(clip.imageUrl())) ); }) ); } void _loadClips(int offset) { if (this._isLoading) { return; } setState(() { this._isLoading = true; }); new AwesomeApi().getClipList(offset: offset).then((x) { setState(() { this._isError = false; this._objectList.insertAll(this._objectList.length, x); this._isLoading = false; }); }).catchError((e) { setState(() { this._isError = true; this._errorMessage = e.toString(); this._isLoading = false; }); }); } } |
『スクロール位置がListViewの底まで来た』を検知できるようにする
NotificationListener<T extends Notification>
というWidgetがあるのでこれを使います。
Tに何入れんの?
今回はスクロールの様子を捕捉したいのでScrollNotification
というヤツを使ってます。
extentAfterの値でスクロールポジションが底か否か判断してます。
他にイケてる方法ありましたら、イケてるエントリー(笑)書いてもらって是非とも本記事を駆逐して欲しいです。
これで『スクロール位置がListViewの底まで来た』を検知できるようになりました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
return new NotificationListener<ScrollNotification>( onNotification: (ScrollNotification value) { // extentAfterで現在のスクロールポジションから下方向にどんだけ領域あるか分かる // extentAfterが0ってことは下にもう領域がない、つまり底にいますわ。ってことになる。 if (value.metrics.extentAfter == 0.0) { this._loadClips(this._objectList.length); } }, child: new ListView.builder( padding: new EdgeInsets.all(4.0), itemCount: this._objectList.length, itemExtent: 300.0, itemBuilder: (BuildContext context, int index) { Clip clip = this._objectList[index]; return new Card( child: new Padding(padding: new EdgeInsets.all(16.0), child: new Image.network(clip.imageUrl())) ); }) ); |
_loadClipにoffsetを渡して、次に追加すべきClipのリストをゲットする
this._loadClips(this._objectList.length);
これですね。
setStateで状態を更新
更新しましょう。
this._objectList.insertAll(this._objectList.length, x);
insertAll良いですね。
細かい話
無限スクロールをやるために、ローディングしてるか否かフラグ(_isLoading)を追加しています。
で、_loadClip呼ばれたタイミングでフラグ立てて、何回もAPI叩かないようにしてます。
で、『無事にClipのリストを取得した or Die』のタイミングでフラグを落としてます。
なんかナイーブな実装でアレな感じですね…
PullToRefresh(ひっぱり更新)するListViewしたい
RefreshIndicator
というWidgetを使います。
実装例は下記のとおり。
どうでもいいけど、これWidgetのネストがヤバいですね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
class _HomePageState extends State<HomePage> { List<Clip> _objectList = []; bool _isLoading = false; bool _isError = false; String _errorMessage = ""; @override void initState() { super.initState(); this._loadClips(0); } @override Widget build(BuildContext context) { if (this._isError) { return new Center(child: new Text(this._errorMessage),); } return new RefreshIndicator( onRefresh: this._handleRefresh, child: new NotificationListener<ScrollNotification>( onNotification: (ScrollNotification value) { if (value.metrics.extentAfter == 0.0) { this._loadClips(this._objectList.length); } }, child: new ListView.builder( padding: new EdgeInsets.all(4.0), itemCount: this._objectList.length, itemExtent: 300.0, itemBuilder: (BuildContext context, int index) { Clip clip = this._objectList[index]; return new Card( child: new Padding(padding: new EdgeInsets.all(16.0), child: new Image.network(clip.imageUrl())) ); }) )); } Future<Null> _handleRefresh() { final Completer<Null> completer = new Completer<Null>(); this._loadClips(0, shouldRefresh: true).then((_) { completer.complete(); }); return completer.future; } Future<List<Clip>> _loadClips(int offset, {shouldRefresh: false}) { if (this._isLoading) { return null; } setState(() { this._isLoading = true; }); return new AwesomeApi().getClipList(offset: offset).then((x) { if (shouldRefresh) { setState(() { this._objectList.clear(); }); } setState(() { this._isError = false; this._objectList.insertAll(this._objectList.length, x); this._isLoading = false; }); }).catchError((e) { setState(() { this._isError = true; this._errorMessage = e.toString(); this._isLoading = false; }); }); } } |
onRefresh
にやってやりたい処理を書けばOK。
onRefreshのハンドラメソッドではなぜかFutureを返り値として要求してくるので、適当に返すようにしてください。
それからAwesomeApiでFutureを返すように変更してます。
onRefreshで生成したcompleterのcompleteを呼び出したいからです。
他にもListViewのあるある要件いろいろあるので整理して随時更新予定
336px
336px
関連記事
-
-
Docker Machineのメモ
随時 …
-
-
開発で詰まったときにググるキーワード
そも …