react-query를 사용해보니 편하고 빠르게 작업할 수 있어서 다른 프레임워크 사용시에도 유사한 라이브러리를 찾아 쓰게된다.
특히 sveltekit 사용시에 쓸 수 있는 svelte-query는 출력방식을 제외하고는 거의 90%가 유사해서 추가 공부 시간이 필요없었다
flutter에도 유사한 라이브러리가 있나 레딧에 검색하다 cached uery 라는 라이브러리를 발견했다
공식문서를 보니 실제로 SWR, React Query, RTKQuery에서 영감을 받았다고 써있기도 하고 친숙한 방식의 라이브러리라 선택했다
일반 dart 언어에서는 cached_query를 설치하고 flutter 에서는 cached_query_flutter를 설치하면된다
flutter pub add cached_query_flutter
cached_query를 설치하지 않도록 주의하자
(cached_query_flutter 검색해도 cached_query가 먼저나옴)
/lib
/models // 불러올 json 객체 모델
/service // query 생성 및 query key 관리
/page // 출력 페이지
"https://jsonplaceholder.typicode.com/posts?_limit=10&_page=$arg"
다음과 같은 데이터를 리턴해준다
[
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
},
...
]
CachedQuery
를 호출한다CachedStorage
도 사용했는데 에뮬레이터에서는 문제가 없으나 크롬 브라우저 실행시 오류가나서 사용을 보류했다void main() async {
WidgetsFlutterBinding.ensureInitialized();
CachedQuery.instance.configFlutter(
config: QueryConfigFlutter(
refetchOnResume: true,
refetchOnConnection: true,
),
// web에서 오류
// storage: await CachedStorage.ensureInitialized(),
);
runApp(const MyApp());
}
0-2의 테스트 api 데이터를 참고하여 아래와 같은 모델을 만든다
class PostModel {
final String title;
final int id;
final String body;
final int userId;
PostModel({
required this.title,
required this.id,
required this.body,
required this.userId,
});
factory PostModel.fromJson(Map<String, dynamic> json) => PostModel(
title: json["title"],
body: json["body"],
id: json["id"],
userId: json["userId"],
);
static List<PostModel> listFromJson(List<dynamic> json) => json
.map((dynamic e) => PostModel.fromJson(e as Map<String, dynamic>))
.toList();
}
1에서 생성한 모델로 InfiniteQuery
를 생성한다
getNextArg
에서 페이지 증가가 발생하며 데이터가 더이상 없는 빈배열일시 state.lastPage?.isEmpty
가 true를 반환한다InfiniteQuery<List<PostModel>, int> getPosts() {
return InfiniteQuery<List<PostModel>, int>(
key: 'posts',
config: QueryConfig(
refetchDuration: const Duration(seconds: 2),
// use a serializer for cached storage
serializer: (dynamic postJson) {
return (postJson as List<dynamic>)
.map(
(dynamic page) => PostModel.listFromJson(page as List<dynamic>),
)
.toList();
},
),
getNextArg: (state) {
if (state.lastPage?.isEmpty ?? false) return null;
return state.length + 1;
},
queryFn: (arg) async {
final uri = Uri.parse(
'https://jsonplaceholder.typicode.com/posts?_limit=10&_page=$arg',
);
final res = await http.get(uri);
return PostModel.listFromJson(
List<Map<String, dynamic>>.from(
jsonDecode(res.body) as List<dynamic>,
),
);
},
);
}
if (state.status == QueryStatus.error)
Container(...)
if문 아래 한칸 들여쓰기로 조건에 맞는 위젯을 넣을 수 있다
class PostListScreen extends StatefulWidget {
static const routeName = '/';
const PostListScreen({Key? key}) : super(key: key);
State<PostListScreen> createState() => _PostListScreenState();
}
class _PostListScreenState extends State<PostListScreen> {
final _scrollController = ScrollController();
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
Widget build(BuildContext context) {
return Scaffold(
appBar: const HeaderMain(
title: '무한스크롤',
),
body: InfiniteQueryBuilder<List<PostModel>, int>(
query: getPosts(),
builder: (context, state, query) {
final allPosts = state.data?.expand((e) => e).toList();
return CustomScrollView(
controller: _scrollController,
slivers: [
if (state.status == QueryStatus.error)
SliverToBoxAdapter(
child: DecoratedBox(
decoration:
BoxDecoration(color: Theme.of(context).errorColor),
child: Text(
state.error is SocketException
? "No internet connection"
: state.error.toString(),
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
if (allPosts != null)
SliverList(
delegate: SliverChildBuilderDelegate(
(context, i) => PostList(
post: allPosts[i],
index: i,
),
childCount: allPosts.length,
),
),
if (state.status == QueryStatus.loading)
const SliverToBoxAdapter(
child: Center(
child: SizedBox(
height: 40,
width: 40,
child: CircularProgressIndicator(),
),
),
),
SliverPadding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
)
],
);
}),
);
}
// 최하단 도달시 다음페이지 패치
void _onScroll() {
final query = getPosts();
if (_isBottom && query.state.status != QueryStatus.loading) {
query.getNextPage();
}
}
// 최하단 판별
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
// 메모리 해제
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}
}