跪拜 Guibai
← Back to the summary

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:

NRefreshable.png

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

  1. 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.

  1. Use NListRefreshController and NRefreshController to perform internal state operations, which can be extended as needed.

github