A Five-Layer Mixin Stack Eliminates Boilerplate Pull-to-Refresh in Flutter
1. Requirement Source
In development, 90% of pages need pull-to-refresh functionality, so I've been thinking about whether there is a perfect encapsulation approach to abstract the common refresh logic. This can improve development efficiency, ease maintenance, and reduce bugs. After polishing across multiple apps and two major upgrades, it has reached a satisfactory state, and I'm sharing it here.
Architecture:
1. The first layer, NRefreshable, is the native interface, providing only common parameters and methods.
abstract interface class NRefreshable {
bool get isLoading;
set isLoading(bool value);
IndicatorResult get indicator;
set indicator(IndicatorResult value);
Future<void> onRefresh();
Future<void> onLoad();
/// Update UI
void updateUI();
}
2. The second layer creates new interfaces based on NRefreshable, supplementing necessary parameters and methods for different scenarios.
/// Mixin for list pages
mixin NListRefreshable<T> on NRefreshable {
// Preset list (for dialogs, request first page data before showing the page)
List<T> get firstPageItems;
set firstPageItems(List<T> value);
List<T> get items;
set items(List<T> value);
int get page;
set page(int value);
int get pageSize;
set pageSize(int value);
/// Update data source
void updateItems(List<T> list);
}
/// Mixin for detail pages
mixin NModelRefreshable<T> on NRefreshable {
T? get item;
set item(T? value);
/// Update data source
void updateItem(T v);
}
3. The third layer implements the refresh logic (data request, UI refresh) based on the second layer interfaces.
/// EasyRefresh mixin, controller available
mixin NListRefreshMixin<T> implements NListRefreshable<T> {
var refreshController = EasyRefreshController(
controlFinishRefresh: true,
controlFinishLoad: true,
);
/// Request method
late RequestListCallback<T> _onRequest;
RequestListCallback<T> get onRequest => _onRequest;
set onRequest(RequestListCallback<T> value) {
_onRequest = value;
}
/// Data list
@override
List<T> items = [];
@override
List<T> firstPageItems = [];
@override
int page = 1;
@override
int pageSize = 20;
@override
var indicator = IndicatorResult.success;
@override
bool isLoading = false;
bool get hasMore => indicator != IndicatorResult.noMore;
@override
Future<void> onRefresh() async {
try {
if (isLoading) {
refreshController.finishRefresh();
return;
}
isLoading = true;
page = 1;
final list = firstPageItems.isNotEmpty ? firstPageItems : await onRequest(true, page, pageSize, <T>[]);
// items.replaceRange(0, items.length, list);
items = [...list];
page++;
indicator = list.length < pageSize ? IndicatorResult.noMore : IndicatorResult.success;
refreshController.finishRefresh();
refreshController.resetFooter();
} catch (e) {
refreshController.finishRefresh(IndicatorResult.fail);
} finally {
isLoading = false;
updateUI();
}
}
@override
Future<void> onLoad() async {
if (indicator == IndicatorResult.noMore) {
refreshController.finishLoad(indicator);
return;
}
try {
if (isLoading) {
refreshController.finishLoad(indicator);
return;
}
isLoading = true;
final start = (items.length - pageSize).clamp(0, pageSize);
final prePages = items.sublist(start);
final list = await onRequest(false, page, pageSize, prePages);
// items.addAll(list);
items = [...items, ...list];
page++;
indicator = list.length < pageSize ? IndicatorResult.noMore : IndicatorResult.success;
refreshController.finishLoad(indicator);
} catch (e) {
refreshController.finishLoad(IndicatorResult.fail);
} finally {
isLoading = false;
updateUI();
}
}
@override
void updateItems(List<T> list) {
items = [...list];
}
@override
void updateUI() => throw UnimplementedError('updateUI');
}
/// EasyRefresh mixin, controller available
mixin NModelRefreshMixin<T> implements NModelRefreshable<T> {
var refreshController = EasyRefreshController(
controlFinishRefresh: true,
controlFinishLoad: true,
);
/// Request method
late RequestModelCallback<T> _onRequest;
RequestModelCallback<T> get onRequest => _onRequest;
set onRequest(RequestModelCallback<T> value) {
_onRequest = value;
}
/// Data list
@override
T? item;
@override
var indicator = IndicatorResult.success;
@override
bool isLoading = false;
@override
Future<void> onRefresh() async {
try {
if (isLoading) {
refreshController.finishRefresh();
return;
}
isLoading = true;
item = await onRequest();
indicator = item == null ? IndicatorResult.fail : IndicatorResult.success;
refreshController.finishRefresh(indicator);
refreshController.resetFooter();
} catch (e) {
refreshController.finishRefresh(IndicatorResult.fail);
} finally {
isLoading = false;
updateUI();
}
}
@override
Future<void> onLoad() => throw UnimplementedError('onLoad');
@override
void updateItem(T v) {
item = v;
updateUI();
}
@override
void updateUI() => throw UnimplementedError('updateUI');
}
Controller class support
class NListRefreshController<T> {
NListRefreshable<T>? _anchor;
void attach(NListRefreshable<T> anchor) {
_anchor = anchor;
}
void detach(NListRefreshable<T> anchor) {
if (_anchor == anchor) {
_anchor = null;
}
}
List<T> get items {
assert(_anchor != null);
return _anchor!.items;
}
void onRefresh() {
assert(_anchor != null);
_anchor!.onRefresh();
}
/// Decrement page number by one
void turnPrePage() {
assert(_anchor != null);
_anchor!.page--;
}
/// Increment page number by one
void turnNextPage() {
assert(_anchor != null);
_anchor!.page++;
}
void updateItems(List<T> list) {
assert(_anchor != null);
_anchor!.updateItems(list);
}
void updateUI() {
assert(_anchor != null);
_anchor!.updateUI();
}
}
/// List refresh controller, used with NModelRefreshable
class NRefreshController<T> {
NModelRefreshable<T>? _anchor;
void attach(NModelRefreshable<T> anchor) {
_anchor = anchor;
}
void detach(NModelRefreshable<T> anchor) {
if (_anchor == anchor) {
_anchor = null;
}
}
T? get item {
assert(_anchor != null);
return _anchor!.item;
}
Future<void> onRefresh() {
assert(_anchor != null);
return _anchor!.onRefresh();
}
void updateItem(T v) {
assert(_anchor != null);
_anchor!.updateItem(v);
}
void updateUI() {
assert(_anchor != null);
_anchor!.updateUI();
}
}
4. The fourth layer implements all refresh logic in the State mixin based on the third layer interfaces.
/// EasyRefresh mixin, used by StatefulWidget list pages
mixin NListRefreshStateMixin<W extends StatefulWidget, T> on State<W>, NListRefreshMixin<T> {
@override
void dispose() {
refreshController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
// DLog.d([widget.title, widget.key, hashCode]);
if (items.isEmpty) {
onRefresh();
}
});
}
@override
void updateItems(List<T> list) {
items.replaceRange(0, items.length, list);
updateUI();
}
@override
void updateUI() {
setState(() {});
}
}
/// EasyRefresh mixin, used by StatefulWidget detail pages
mixin NRefreshStateMixin<W extends StatefulWidget, T> on State<W>, NModelRefreshMixin<T> {
@override
void dispose() {
refreshController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (item == null) {
onRefresh();
}
});
}
@override
void updateUI() {
setState(() {});
}
}
5. The fifth layer implements component encapsulation based on the fourth layer mixins.
List page refresh component
//
// NCustomScrollView.dart
// projects
//
// Created by shang on 2026/1/28 14:41.
// Copyright © 2026/1/28 shang. All rights reserved.
//
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/n_placeholder.dart';
import 'package:flutter_templet_project/basicWidget/refresh/n_easy_refresh_mixin.dart';
/// A scrollable list based on CustomScrollView with pull-to-refresh and load-more
/// Can be used with NestedScrollView to add sticky header components
class NCustomScrollView<T> extends StatefulWidget {
const NCustomScrollView({
super.key,
this.controller,
this.scrollController,
this.placeholder = const NPlaceholder(),
this.contentDecoration = const BoxDecoration(),
this.contentPadding = const EdgeInsets.all(0),
this.onlyHeader = false,
required this.onRequest,
required this.itemBuilder,
this.separatorBuilder,
this.headerBuilder,
this.footerBuilder,
this.builder,
});
/// Refresh controller
final NListRefreshController<T>? controller;
final ScrollController? scrollController;
final Widget? placeholder;
final Decoration contentDecoration;
final EdgeInsets contentPadding;
/// Whether the header can be displayed when the list is empty
final bool onlyHeader;
/// Request method
final RequestListCallback<T> onRequest;
/// ListView's itemBuilder
final ValueIndexedWidgetBuilder<T> itemBuilder;
final IndexedWidgetBuilder? separatorBuilder;
/// List header
final List<Widget> Function(int count)? headerBuilder;
/// List footer
final List<Widget> Function(int count)? footerBuilder;
final Widget Function(List<T> items)? builder;
@override
State<NCustomScrollView<T>> createState() => _NCustomScrollViewState<T>();
}
class _NCustomScrollViewState<T> extends State<NCustomScrollView<T>>
with AutomaticKeepAliveClientMixin, NListRefreshMixin<T>, NListRefreshStateMixin<NCustomScrollView<T>, T> {
@override
bool get wantKeepAlive => true;
final scrollController = ScrollController();
@override
late RequestListCallback<T> onRequest = widget.onRequest;
@override
void dispose() {
widget.controller?.detach(this);
super.dispose();
}
@override
void initState() {
super.initState();
widget.controller?.attach(this);
}
@override
void didUpdateWidget(covariant NCustomScrollView<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.scrollController != oldWidget.scrollController ||
widget.placeholder != oldWidget.placeholder ||
widget.contentDecoration != oldWidget.contentDecoration ||
widget.contentPadding != oldWidget.contentPadding ||
widget.onlyHeader != oldWidget.onlyHeader ||
widget.onRequest != oldWidget.onRequest ||
widget.itemBuilder != oldWidget.itemBuilder ||
widget.separatorBuilder != oldWidget.separatorBuilder) {
if (widget.controller != null && oldWidget.controller != widget.controller) {
oldWidget.controller?.detach(this);
widget.controller?.attach(this);
}
setState(() {});
}
}
@override
Widget build(BuildContext context) {
super.build(context);
if (items.isEmpty && !widget.onlyHeader) {
return GestureDetector(onTap: onRefresh, child: Center(child: widget.placeholder));
}
return EasyRefresh.builder(
controller: refreshController,
onRefresh: onRefresh,
onLoad: indicator == IndicatorResult.noMore ? null : onLoad,
childBuilder: (_, physics) {
return CustomScrollView(
controller: widget.scrollController,
physics: physics,
slivers: [
...(widget.headerBuilder?.call(items.length) ?? []),
buildContent(),
...(widget.footerBuilder?.call(items.length) ?? []),
],
);
},
);
}
Widget buildContent() {
if (items.isEmpty) {
return SliverToBoxAdapter(child: Center(child: widget.placeholder));
}
return DecoratedSliver(
decoration: widget.contentDecoration,
sliver: SliverPadding(
padding: widget.contentPadding,
sliver: widget.builder?.call(items) ?? buildSliverList(),
),
);
}
Widget buildSliverList() {
return SliverList.separated(
itemBuilder: (_, i) => widget.itemBuilder(context, i, items[i]),
separatorBuilder: (_, i) => widget.separatorBuilder?.call(context, i) ?? const SizedBox(),
itemCount: items.length,
);
}
@override
void updateUI() {
setState(() {});
}
}
Detail page refresh component
//
// NCustomScrollViewOne.dart
// projects
//
// Created by shang on 2026/1/28 14:41.
// Copyright © 2026/1/28 shang. All rights reserved.
//
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_templet_project/basicWidget/n_placeholder.dart';
import 'package:flutter_templet_project/basicWidget/n_skeleton_screen.dart';
import 'package:flutter_templet_project/basicWidget/refresh/n_easy_refresh_mixin.dart';
/// Detail display based on CustomScrollView with pull-to-refresh
/// Can be used with NestedScrollView to add sticky header components
class NCustomScrollViewForModel<T> extends StatefulWidget {
const NCustomScrollViewForModel({
super.key,
this.controller,
this.scrollController,
this.placeholder = const NPlaceholder(),
this.skeletonScreen = const NSkeletonScreen(),
this.contentDecoration = const BoxDecoration(),
this.contentPadding = const EdgeInsets.all(0),
this.onlyHeader = false,
required this.onRequest,
required this.builder,
this.headerBuilder,
this.footerBuilder,
});
/// Refresh controller
final NRefreshController<T>? controller;
final ScrollController? scrollController;
final Widget? placeholder;
final Widget? skeletonScreen;
final Decoration contentDecoration;
final EdgeInsets contentPadding;
/// Whether the header can be displayed when the list is empty
final bool onlyHeader;
/// Request method
final RequestModelCallback<T> onRequest;
final Widget Function(BuildContext context, T? model) builder;
/// Header
final List<Widget> Function(BuildContext context, T? m)? headerBuilder;
/// Footer
final List<Widget> Function(BuildContext context, T? m)? footerBuilder;
@override
State<NCustomScrollViewForModel<T>> createState() => _NCustomScrollViewForModelState<T>();
}
class _NCustomScrollViewForModelState<T> extends State<NCustomScrollViewForModel<T>>
with AutomaticKeepAliveClientMixin, NModelRefreshMixin<T>, NRefreshStateMixin<NCustomScrollViewForModel<T>, T> {
@override
bool get wantKeepAlive => true;
late var scrollController = widget.scrollController ?? ScrollController();
@override
late RequestModelCallback<T> onRequest = widget.onRequest;
/// First load
var isFirstLoad = true;
@override
void dispose() {
widget.controller?.detach(this);
refreshController.dispose();
scrollController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
widget.controller?.attach(this);
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (item == null) {
await onRefresh();
isFirstLoad = false;
}
});
}
@override
void didUpdateWidget(covariant NCustomScrollViewForModel<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller ||
widget.scrollController != oldWidget.scrollController ||
widget.placeholder != oldWidget.placeholder ||
widget.contentDecoration != oldWidget.contentDecoration ||
widget.contentPadding != oldWidget.contentPadding ||
widget.onlyHeader != oldWidget.onlyHeader ||
widget.onRequest != oldWidget.onRequest) {
if (widget.controller != null && oldWidget.controller != widget.controller) {
oldWidget.controller?.detach(this);
widget.controller?.attach(this);
}
if (widget.scrollController != null) {
scrollController = widget.scrollController!;
}
onRequest = widget.onRequest;
setState(() {});
}
}
@override
Widget build(BuildContext context) {
super.build(context);
if (isFirstLoad && widget.skeletonScreen != null) {
return widget.skeletonScreen!;
}
if (item == null && !widget.onlyHeader) {
return GestureDetector(onTap: onRefresh, child: Center(child: widget.placeholder));
}
return EasyRefresh.builder(
controller: refreshController,
scrollController: scrollController,
onRefresh: onRefresh,
onLoad: null,
childBuilder: (_, physics) {
return CustomScrollView(
controller: scrollController,
physics: physics,
slivers: [
...(widget.headerBuilder?.call(context, item) ?? []),
buildContent(),
...(widget.footerBuilder?.call(context, item) ?? []),
],
);
},
);
}
Widget buildContent() {
if (item == null) {
return SliverToBoxAdapter(child: widget.placeholder);
}
return DecoratedSliver(
decoration: widget.contentDecoration,
sliver: SliverPadding(
padding: widget.contentPadding,
sliver: widget.builder(context, item),
),
);
}
@override
void updateUI() {
setState(() {});
}
}
Finally, Summary
- Sliver components have better adaptability and cover more scenarios; non-Sliver components are more convenient to use. For complex pages, it's recommended to just use NCustomScrollView/NCustomScrollViewForModel.
List components: NCustomScrollView (Sliver) and NRefreshListView.
Detail page components: NCustomScrollViewForModel (Sliver) and NRefreshView.
- Use NListRefreshController and NRefreshController to perform internal state operations, which can be extended as needed.