Objeto: "infinite scroll" com acesso a dados via API.
Principais termos técnicos abordados: programação assíncrona, async, await, Future, api, end-point, números mágicos
Requisitos para as instruções funcionarem: navegador ou ide (VS Code, Android Studio etc.)
Requisitos para compreensão das instruções: noções básicas de programação e ter realizado receita anterior, com exercícios
Como ler essa receita: instruções, dentro dos passos da receita e que requerem ação sua no computador, estarão escritas em cor azul. Comentários sobre os passos estarão em fonte normal, cor preta. Comandos, código-fonte, termos técnicos ou configuração explícita, estarão com fonte diferenciada.
Observação: eventuais instruções de terminal serão comandos Linux. Adapte caso esteja usando outro sistema operacional
Essa será uma receita bem prática, sem muita explicação. Nosso objetivo é acrescentar "scroll infinito" nas telas de cervejas, cafés e nações. No meio do caminho. Abordaremos um ou outro assunto tangencial para deixar o código mais bonitinho, mas também sem explicações alongadas.
O código inicial funcionará como a animação a seguir, e ele é bem parecido que o que foi programado nesta receita.
Em síntese, temos já as consultas sendo feitas aos toques nos botões de baixo e o resultado das concultas está montando um ListView com dados da consulta realizada.
O ListView já permite naturalmente uma barra de rolagem sem que a gente tenha nenhum trabalho.
O que queremos implementar é o seguinte: quando houver cervejas sendo exibidas e o usuário descer até o final da barra de rolagem, o app carrega novas cervejas e as exibe abaixo das cervejas já existentes. Esse efeito é conhecido como "infinite scroll" - rolagem infinita - é é bastante comum em apps de redes sociais.
Cole o código inicial. Você precisará, também, acrescentar os pacotes http e flutter_hooks, caso já não tenha feito isso.
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
enum TableStatus{idle,loading,ready,error}
class DataService{
final ValueNotifier<Map<String,dynamic>> tableStateNotifier
= ValueNotifier({
'status':TableStatus.idle,
'dataObjects':[]
});
void carregar(index){
final funcoes = [carregarCafes, carregarCervejas, carregarNacoes];
tableStateNotifier.value = {
'status': TableStatus.loading,
'dataObjects': []
};
funcoes[index]();
}
void carregarCafes(){
var coffeesUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/coffee/random_coffee',
queryParameters: {'size': '10'});
http.read(coffeesUri).then( (jsonString){
var coffeesJson = jsonDecode(jsonString);
tableStateNotifier.value = {
'status': TableStatus.ready,
'dataObjects': coffeesJson,
'propertyNames': ["blend_name","origin","variety"],
'columnNames': ["Nome", "Origem", "Tipo"]
};
});
}
void carregarNacoes(){
var nationsUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/nation/random_nation',
queryParameters: {'size': '10'});
http.read(nationsUri).then( (jsonString){
var nationsJson = jsonDecode(jsonString);
tableStateNotifier.value = {
'status': TableStatus.ready,
'dataObjects': nationsJson,
'propertyNames': ["nationality","capital","language","national_sport"],
'columnNames': ["Nome", "Capital", "Idioma","Esporte"]
};
});
}
void carregarCervejas(){
var beersUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/beer/random_beer',
queryParameters: {'size': '10'});
http.read(beersUri).then( (jsonString){
var beersJson = jsonDecode(jsonString);
tableStateNotifier.value = {
'status': TableStatus.ready,
'dataObjects': beersJson,
'propertyNames': ["name","style","ibu"],
'columnNames': ["Nome", "Estilo", "IBU"]
};
});
}
}
final dataService = DataService();
void main() {
MyApp app = MyApp();
runApp(app);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.deepPurple),
debugShowCheckedModeBanner:false,
home: Scaffold(
appBar: AppBar(
title: const Text("Dicas"),
),
body: ValueListenableBuilder(
valueListenable: dataService.tableStateNotifier,
builder:(_, value, __){
switch (value['status']){
case TableStatus.idle:
return Center(child: Text("Toque algum botão, abaixo..."));
case TableStatus.loading:
return Center(child: CircularProgressIndicator());
case TableStatus.ready:
return ListWidget(
jsonObjects: value['dataObjects'],
propertyNames: value['propertyNames']
);
case TableStatus.error:
return Text("Lascou");
}
return Text("...");
}
),
bottomNavigationBar: NewNavBar(itemSelectedCallback: dataService.carregar),
));
}
}
class NewNavBar extends HookWidget {
final _itemSelectedCallback;
NewNavBar({itemSelectedCallback}):
_itemSelectedCallback = itemSelectedCallback ?? (int){}
@override
Widget build(BuildContext context) {
var state = useState(1);
return BottomNavigationBar(
onTap: (index){
state.value = index;
_itemSelectedCallback(index);
},
currentIndex: state.value,
items: const [
BottomNavigationBarItem(
label: "Cafés",
icon: Icon(Icons.coffee_outlined),
),
BottomNavigationBarItem(
label: "Cervejas", icon: Icon(Icons.local_drink_outlined)),
BottomNavigationBarItem(
label: "Nações", icon: Icon(Icons.flag_outlined))
]);
}
}
class ListWidget extends StatelessWidget {
final List jsonObjects;
final List<String> propertyNames;
ListWidget( {this.jsonObjects = const [], this.propertyNames= const ["name", "style", "ibu"]});
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: EdgeInsets.all(10),
separatorBuilder: (_,__) => Divider(
height: 5,
thickness: 2,
indent: 10,
endIndent: 10,
color: Theme.of(context).primaryColor,
),
itemCount: jsonObjects.length,
itemBuilder: (_, index){
var title = jsonObjects[index][propertyNames[0]];
var content = propertyNames
.sublist(1)
.map( (prop) => jsonObjects[index][prop] )
.join(" - ");
return Card(
shadowColor: Theme.of(context).primaryColor,
child: Column(
children: [
SizedBox(height: 10),
//a primeira propriedade vai em negrito
Text(
"${title}\n",
style: TextStyle(fontWeight: FontWeight.bold)
),
//as demais vão normais
Text(content),
SizedBox(height: 10)
]
)
);
},
);
}
}
A barra de rolagem tá bonitinha já.
Mas precisamos detectar quando essa rolagem chega ao fim, para início de conversa. Assim podemos desencadear alguma ação para carregar mais itens.
Para detectar essa rolagem a gente vai precisar de um objeto ScrollController.
E a gente vai precisar registrar uma função de callback nesse objeto ScrollController. Essa função de callback é a gente que escreve e nela a gente desencadeia as ações necessárias.
Modifique sua classe ListWidget.
class ListWidget extends HookWidget {
final List jsonObjects;
final List<String> propertyNames;
ListWidget( {this.jsonObjects = const [], this.propertyNames= const ["name", "style", "ibu"]});
@override
Widget build(BuildContext context) {
var controller = useScrollController();
useEffect(
(){
controller.addListener(
(){
if (controller.position.pixels == controller.position.maxScrollExtent)
print('end of scroll');
}
);
},[controller]
);
return ListView.separated(
controller: controller,
padding: EdgeInsets.all(10),
separatorBuilder: (_,__) => Divider(
height: 5,
thickness: 2,
indent: 10,
endIndent: 10,
color: Theme.of(context).primaryColor,
),
itemCount: jsonObjects.length,
itemBuilder: (_, index){
var title = jsonObjects[index][propertyNames[0]];
var content = propertyNames
.sublist(1)
.map( (prop) => jsonObjects[index][prop] )
.join(" - ");
return Card(
shadowColor: Theme.of(context).primaryColor,
child: Column(
children: [
SizedBox(height: 10),
//a primeira propriedade vai em negrito
Text(
"${title}\n",
style: TextStyle(fontWeight: FontWeight.bold)
),
//as demais vão normais
Text(content),
SizedBox(height: 10)
]
)
);
},
);
}
}
Essencialmente, transformamos o ListWidget num HookWidget com...
extends HookWidget
Fizemos isso para usar os hooks.
Em especial poque tem um hook, o useScrollController() que nos dá um objeto ScrollController.
Estamos registrando uma função de callback no controller, essa função vai detectar que a rolagem chegou ao fim. Por enquanto, damos apenas um print.
controller.addListener(
(){
if (controller.position.pixels == controller.position.maxScrollExtent)
print('end of scroll');
}
);
No entanto, esse objeto controller ele sobrevive a redesenhos do componente, mais ou menos como o estado interno sobreviveria a redesenhos. Se a gente escrever essa linha incondicionalmente, vamos estar adicionando essa mesma função de callback a cada re-renderização. E a gente quer adicionar apenas quando o componente for renderizado pela primeira vez, ou seja, queremos fazer essa chamada controller.addListener apenas uma vez após a primeira renderização do nosso widget. Tem outro hook que nos permite fazer exatamente isso, o useEffect, se chamado dessa forma...
useEffect(
(){
//Código chamado após a primeira renderização do componente
},[controller]
);
Esse código dentro do useEffect não vai ser executado novamente enquanto o objeto controller não mudar. Resolve nossa parada, esse hook useEffect foi feito pra esse tipo de coisa.
Por fim, passamos o controlador que a gente criou para a fábrica do ListView.
(...)
return ListView.separated(
controller: controller,
padding: EdgeInsets.all(10),
(...)
Salve, rode e veja que funciona - não vou fazer outro gif animado só pra isso ;)
Vamos precisar fazer algo mais interessante que um print quando chegar ao fim da rolagem.
E vamos precisar por uma barra de indicação de progresso ao fim da lista, para dar a ideia de que novos itens estao sendo carregados.
Comecemos por esse último ponto.
Há muitas formas de se fazer isso, e eu vou fazer assim: vou por sempre um item a mais no ListView, e esse item a mais vai ser uma barra de progresso. Isso é meio que tapear, porque a barra de progresso, tecnicamente, já vai estar lá antes de chegarmos ao fim da lista, mas acontece que essa barra de progresso só vai ser exibida, de toda forma, quando alcançármos o fim da rolagem, então, nesse nosso caso, não precisa realmente redesenhar o componente inteiro acrescentando uma barra de progresso só quando a rolagem chegar ao fim. Desenhamos logo porque ela só será visível mesmo quando chegar ao fim a rolagem.
Modifique o ListWidget.
class ListWidget extends HookWidget {
final List jsonObjects;
final List<String> propertyNames;
ListWidget( {this.jsonObjects = const [], this.propertyNames= const ["name", "style", "ibu"]});
@override
Widget build(BuildContext context) {
var controller = useScrollController();
useEffect(
(){
controller.addListener(
(){
if (controller.position.pixels == controller.position.maxScrollExtent)
print('end of scroll');
}
);
},[]
);
return ListView.separated(
controller: controller,
padding: EdgeInsets.all(10),
separatorBuilder: (_,__) => Divider(
height: 5,
thickness: 2,
indent: 10,
endIndent: 10,
color: Theme.of(context).primaryColor,
),
itemCount: jsonObjects.length+1,
itemBuilder: (_, index){
if (index==jsonObjects.length)
return Center(child: LinearProgressIndicator());
var title = jsonObjects[index][propertyNames[0]];
var content = propertyNames
.sublist(1)
.map( (prop) => jsonObjects[index][prop] )
.join(" - ");
return Card(
shadowColor: Theme.of(context).primaryColor,
child: Column(
children: [
SizedBox(height: 10),
//a primeira propriedade vai em negrito
Text(
"${title}\n",
style: TextStyle(fontWeight: FontWeight.bold)
),
//as demais vão normais
Text(content),
SizedBox(height: 10)
]
)
);
},
);
}
}
Por enquanto mexemos apenas com a interface gráfica.
Precisamos fazer a coisa acontecer ao término da rolagem.
Vamos fazer coisa parecida com o que foi feito com o NewNavBar. Vamos acrescentar ao ListWidget uma função de callback. Essa função será passada por quem chamar a fábrica do ListWidget.
Modifique o ListWidget.
class ListWidget extends HookWidget {
final dynamic _scrollEndedCallback;
final List jsonObjects;
final List<String> propertyNames;
ListWidget( {this.jsonObjects = const [], this.propertyNames= const [], void Function()? scrollEndedCallback }):
_scrollEndedCallback = scrollEndedCallback ?? false;
@override
Widget build(BuildContext context) {
var controller = useScrollController();
useEffect(
(){
controller.addListener(
(){
if (controller.position.pixels == controller.position.maxScrollExtent){
print('end reached');
if (_scrollEndedCallback is Function)
_scrollEndedCallback();
}
},
);
},[controller]
);
return ListView.separated(
controller: controller,
padding: EdgeInsets.all(10),
separatorBuilder: (_,__) => Divider(
height: 5,
thickness: 2,
indent: 10,
endIndent: 10,
color: Theme.of(context).primaryColor,
),
itemCount: jsonObjects.length+1,
itemBuilder: (_, index){
if (index==jsonObjects.length)
return Center(child: LinearProgressIndicator());
var title = jsonObjects[index][propertyNames[0]];
var content = propertyNames
.sublist(1)
.map( (prop) => jsonObjects[index][prop] )
.join(" - ");
return Card(
shadowColor: Theme.of(context).primaryColor,
child: Column(
children: [
SizedBox(height: 10),
//a primeira propriedade vai em negrito
Text(
"${title}\n",
style: TextStyle(fontWeight: FontWeight.bold)
),
//as demais vão normais
Text(content),
SizedBox(height: 10)
]
)
);
},
);
}
}
Vamos escrever as funções que devem ser invocadas para recarregar itens. Na verdade, essas funções já estão implementadas - são as funções carregarCervejas, carregarCafes e carregarNacoes. Então vamos apenas passá-las como parâmetro para a fábrica do ListWidget.
A gente teria, basicamente, que mexer por aqui...
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.deepPurple),
debugShowCheckedModeBanner:false,
home: Scaffold(
appBar: AppBar(
title: const Text("Dicas"),
),
body: ValueListenableBuilder(
valueListenable: dataService.tableStateNotifier,
builder:(_, value, __){
switch (value['status']){
case TableStatus.idle:
return Center(child: Text("Toque algum botão, abaixo..."));
case TableStatus.loading:
return Center(child: CircularProgressIndicator());
case TableStatus.ready:
return ListWidget(
jsonObjects: value['dataObjects'],
propertyNames: value['propertyNames'],
scrollEndedCallback = carregarCervejas (ou Cafes ou Nacoes)
);
case TableStatus.error:
return Text("Lascou");
}
return Text("...");
}
),
bottomNavigationBar: NewNavBar(itemSelectedCallback: dataService.carregar),
));
}
}
Sendo que a gente tem que passar apenas uma função de callback para a fábrica do ListWidget, e a gente tem 3 candidatas. Se cervejas estiverem em exibição, a gente tem que passar a função carregarCervejas, se Cafés estiverem em exibição, a gente tem que passar a função carregarCafes, e por aí vai.
É desacaradamente coisa para se resolver com if's.
A questão é se, naquele ponto do código, a gente tem acesso a variáveis que nos permitam escrever expressões booleanas para decidir a função correta. Acontece que, naquele ponto, a gente tem acesso ao estado, com todo o vetor de objetos json. Dá pra inspecionar o propertyNamespara saber o que deve ser exibido. Um if (propertyNames.contains("ibu")) vai nos dizer se devemos passar carregarCervejas como função de callback, por exemplo.
Mas o código ficaria bem seboso desse jeito.
Vamos acrescentar a informação explícita no estado da tabela e reescrever as funções.
enum TableStatus{idle,loading,ready,error}
enum ItemType{beer, coffee, nation, none}
class DataService{
final ValueNotifier<Map<String,dynamic>> tableStateNotifier
= ValueNotifier({
'status':TableStatus.idle,
'dataObjects':[],
'itemType': ItemType.none
});
void carregar(index){
final funcoes = [carregarCafes, carregarCervejas, carregarNacoes];
tableStateNotifier.value = {
'status': TableStatus.loading,
'dataObjects': []
};
funcoes[index](1);
}
void carregarCafes(int page){
var coffeesUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/coffee/random_coffee',
queryParameters: {'size': '10'});
http.read(coffeesUri).then( (jsonString){
var coffeesJson = jsonDecode(jsonString);
tableStateNotifier.value = {
'itemType': ItemType.coffee,
'status': TableStatus.ready,
'dataObjects': coffeesJson,
'propertyNames': ["blend_name","origin","variety"],
'columnNames': ["Nome", "Origem", "Tipo"]
};
});
}
void carregarNacoes(int page){
var nationsUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/nation/random_nation',
queryParameters: {'size': '10'});
http.read(nationsUri).then( (jsonString){
var nationsJson = jsonDecode(jsonString);
tableStateNotifier.value = {
'itemType': ItemType.nation,
'status': TableStatus.ready,
'dataObjects': nationsJson,
'propertyNames': ["nationality","capital","language","national_sport"],
'columnNames': ["Nome", "Capital", "Idioma","Esporte"]
};
});
}
void carregarCervejas(int page){
var beersUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/beer/random_beer',
queryParameters: {'size': '10'});
http.read(beersUri).then( (jsonString){
var beersJson = jsonDecode(jsonString);
tableStateNotifier.value = {
'itemType': ItemType.beer,
'status': TableStatus.ready,
'dataObjects': beersJson,
'propertyNames': ["name","style","ibu"],
'columnNames': ["Nome", "Estilo", "IBU"]
};
});
}
}
Com isso garantimos uma informação explícita no estado da tabela, fica mais fácil fazer a seleção lá no ValueListenableWidget, com if ou de qualquer outra forma.
Vamos lá.
class MyApp extends StatelessWidget {
final functionsMap = {
ItemType.beer: dataService.carregarCervejas,
ItemType.coffee: dataService.carregarCafes,
ItemType.nation: dataService.carregarNacoes
};
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.deepPurple),
debugShowCheckedModeBanner:false,
home: Scaffold(
appBar: AppBar(
title: const Text("Dicas"),
),
body: ValueListenableBuilder(
valueListenable: dataService.tableStateNotifier,
builder:(_, value, __){
switch (value['status']){
case TableStatus.idle:
return Center(child: Text("Toque algum botão, abaixo..."));
case TableStatus.loading:
return Center(child: CircularProgressIndicator());
case TableStatus.ready:
return ListWidget(
jsonObjects: value['dataObjects'],
propertyNames: value['propertyNames'],
scrollEndedCallback: functionsMap[value['itemType']],
);
case TableStatus.error:
return Text("Lascou");
}
return Text("...");
}
),
bottomNavigationBar: NewNavBar(itemSelectedCallback: dataService.carregar),
));
}
}
Veja, era e é, descaradamente, um problema para se resolver com comandos de seleção, mas a gente dispõe de estruturas de dados (o Map, no caso) para evitar uma cadeia de ifs no código. Mas dá pra resolver com if ou switch, claro.
Eu prefiro do jeito que está.
Rodando, no entanto...
... vemos que, para nossa tristeza, o componente está carregando mais 10 itens mas está substituindo os 10 existentes.
A gente precisa modificar os métodos carregar*.
Acontece que eles substituem os itens existentes no estado por pelos itens novos. No entanto, esses métodos, agora, sabem o tipo de item que está sendo exibido e o status da solicitação corrente. Dá pra fazer if tranquilamente em cima disso.
Dessa vez eu vou usar if mesmo.
Modifique o DataService.
class DataService{
final ValueNotifier<Map<String,dynamic>> tableStateNotifier
= ValueNotifier({
'status':TableStatus.idle,
'dataObjects':[],
'itemType': ItemType.none
});
void carregar(index){
final funcoes = [carregarCafes, carregarCervejas, carregarNacoes];
funcoes[index]();
}
void carregarCafes(){
//ignorar solicitação se uma requisição já estiver em curso
if (tableStateNotifier.value['status'] == TableStatus.loading) return;
if (tableStateNotifier.value['itemType'] != ItemType.coffee){
tableStateNotifier.value = {
'status': TableStatus.loading,
'dataObjects': [],
'itemType': ItemType.coffee
};
}
var coffeesUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/coffee/random_coffee',
queryParameters: {'size': '10'});
http.read(coffeesUri).then( (jsonString){
var coffeesJson = jsonDecode(jsonString);
//se já houver cafés no estado da tabela...
if (tableStateNotifier.value['status'] != TableStatus.loading) coffeesJson = [...tableStateNotifier.value['dataObjects'], ...coffeesJson];
tableStateNotifier.value = {
'itemType': ItemType.coffee,
'status': TableStatus.ready,
'dataObjects': coffeesJson,
'propertyNames': ["blend_name","origin","variety"],
'columnNames': ["Nome", "Origem", "Tipo"]
};
});
}
void carregarNacoes(){
//ignorar solicitação se uma requisição já estiver em curso
if (tableStateNotifier.value['status'] == TableStatus.loading) return;
if (tableStateNotifier.value['itemType'] != ItemType.nation){
tableStateNotifier.value = {
'status': TableStatus.loading,
'dataObjects': [],
'itemType': ItemType.nation
};
}
var nationsUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/nation/random_nation',
queryParameters: {'size': '10'});
http.read(nationsUri).then( (jsonString){
var nationsJson = jsonDecode(jsonString);
//se já houver nações no estado da tabela...
if (tableStateNotifier.value['status'] != TableStatus.loading) nationsJson = [...tableStateNotifier.value['dataObjects'], ...nationsJson];
tableStateNotifier.value = {
'itemType': ItemType.nation,
'status': TableStatus.ready,
'dataObjects': nationsJson,
'propertyNames': ["nationality","capital","language","national_sport"],
'columnNames': ["Nome", "Capital", "Idioma","Esporte"]
};
});
}
void carregarCervejas(){
//ignorar solicitação se uma requisição já estiver em curso
if (tableStateNotifier.value['status'] == TableStatus.loading) return;
//emitir estado loading se items em exibição não forem cervejas
if (tableStateNotifier.value['itemType'] != ItemType.beer){
tableStateNotifier.value = {
'status': TableStatus.loading,
'dataObjects': [],
'itemType': ItemType.beer
};
}
var beersUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/beer/random_beer',
queryParameters: {'size': '10'});
http.read(beersUri).then( (jsonString){
var beersJson = jsonDecode(jsonString);
//se já houver cervejas no estado da tabela...
if (tableStateNotifier.value['status'] != TableStatus.loading) beersJson = [...tableStateNotifier.value['dataObjects'], ...beersJson];
tableStateNotifier.value = {
'itemType': ItemType.beer,
'status': TableStatus.ready,
'dataObjects': beersJson,
'propertyNames': ["name","style","ibu"],
'columnNames': ["Nome", "Estilo", "IBU"]
};
});
}
}
E eis que a mágica acontece...
Tem seboseira no DataService. Os métodos carregarCerveja, carregarCafes e carregarNacoes replicam muito código - porque implementam, essencialmente, o mesmo algoritmo. Use seus conhecimentos de programador que bebe leite com Nescau para resolver isso e deixar a classe DataService mais bem implementada.
Que tal naquela barra onde se lê dicas você botar, entre parênteses, a quantidade de itens sendo exibidos?