在应用开发中,下拉刷新是一个常见需求,但处理起来往往比看起来要复杂。我们需要兼顾以下几种状态的无缝切换:

  1. 首次进入页面:显示加载指示器(Spinner)。
  2. 下拉刷新时:显示下拉刷新控件,同时保持当前数据内容不消失(避免屏幕闪烁)。
  3. 加载错误:优雅地展示错误信息。
  4. 刷新结束:隐藏刷新控件并更新数据。

Riverpod 的声明式特性结合 Dart 3 的模式匹配,可以非常简洁地解决这些问题。

1. 准备工作:定义数据模型与 Provider

首先,我们需要定义数据模型以及获取数据的 Provider。这里我们使用 riverpod_generatorfreezed 来生成类型安全的代码。

1.1 定义数据模型 (Activity)

我们将构建一个简单的应用,从 API 获取随机活动建议。使用 Freezed 来处理不可变对象和 JSON 序列化。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'codegen.freezed.dart';
part 'codegen.g.dart';

@freezed
sealed class Activity with _$Activity {
  factory Activity({
    required String activity,
    required String type,
    required int participants,
    required double price,
  }) = _Activity;

  factory Activity.fromJson(Map<String, dynamic> json) =>
      _$ActivityFromJson(json);
}

1.2 创建 API Provider

使用 @riverpod 注解创建一个异步 Provider。该 Provider 负责发起网络请求并返回 Activity 对象。

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

// 生成的 Provider 代码将位于此处
part 'codegen.g.dart';

@riverpod
Future<Activity> activity(ActivityRef ref) async {
  // 模拟网络请求
  final response = await http.get(
    Uri.https('www.boredapi.com', '/api/activity'),
  );

  final json = jsonDecode(response.body) as Map<String, dynamic>;
  return Activity.fromJson(json);
}

2. 核心实现:构建 UI 与刷新逻辑

接下来是 UI 部分的实现。我们需要使用 Flutter 原生的 RefreshIndicator 组件,并结合 Riverpod 的 ref.watchref.refresh

2.1 使用 RefreshIndicator

RefreshIndicator 需要一个可滚动的子组件(如 ListView)。它的 onRefresh 回调必须返回一个 Future,当这个 Future 完成时,刷新指示器才会消失。

2.2 触发刷新 (The Refresh Logic)

这是实现的关键:

  • 不要手动维护一个 isLoading 变量。
  • 使用 ref.refresh(provider.future)

ref.refresh(activityProvider) 会立即使 Provider 失效并重新计算,这通常会返回一个新的状态。但是,RefreshIndicator 需要等待刷新完成。因此,我们需要读取 .future 属性:

onRefresh: () => ref.refresh(activityProvider.future),

这将返回一个新的 Future,直到数据重新加载完毕后才会 resolve,从而完美控制刷新动画的结束时间。

2.3 智能处理加载状态 (Dart 3 模式匹配)

为了达到“刷新时保留旧数据,首次加载显示 Spinner”的效果,我们利用 AsyncValue 结合 Dart 3 的 switch 表达式。

关键点在于模式匹配的顺序:

  1. 优先匹配数据 (:final value?):如果 AsyncValue 中包含数据(无论是当前数据还是刷新前的旧数据),就显示内容。这意味着即使在刷新过程中(状态变为 loading),只要之前有数据,用户依然能看到内容,而不是白屏。
  2. 匹配错误 (:final error?):如果发生错误且没有旧数据,显示错误信息。
  3. 默认情况 (_):既无数据也无错误(即首次加载),显示加载圈。

3. 完整代码示例

以下是整合了上述所有概念的完整代码(基于 riverpod_generator):

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';

// 注意:实际开发中需运行 build_runner 生成这两个文件
part 'main.g.dart';
part 'main.freezed.dart';

void main() => runApp(ProviderScope(child: MyApp()));

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: ActivityView());
  }
}

class ActivityView extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1. 监听 Provider 状态
    final activity = ref.watch(activityProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Pull to refresh')),
      body: RefreshIndicator(
        // 2. 刷新逻辑:刷新 Provider 并等待 Future 完成
        onRefresh: () => ref.refresh(activityProvider.future),
        child: ListView(
          children: [
            // 3. UI 状态渲染:使用 Dart 3 模式匹配
            switch (activity) {
              // 只要有数据(包括刷新期间),就显示数据
              AsyncValue<Activity>(:final value?) => Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: Text(value.activity),
                ),
              // 有错误,显示错误信息
              AsyncValue(:final error?) => Text('Error: $error'),
              // 既无数据也无错误(初始加载),显示 Loading
              _ => const Center(child: CircularProgressIndicator()),
            },
          ],
        ),
      ),
    );
  }
}

// ================= 数据与 Provider 定义 =================

@riverpod
Future<Activity> activity(ActivityRef ref) async {
  // 模拟 API 请求
  final response = await http.get(
    Uri.https('bored-api.appbrewery.com', '/random'),
  );
  // 解析 JSON
  final json = jsonDecode(response.body) as Map<String, dynamic>;
  return Activity.fromJson(json);
}

@freezed
sealed class Activity with _$Activity {
  factory Activity({
    required String activity,
    required String type,
    required int participants,
    required double price,
  }) = _Activity;

  factory Activity.fromJson(Map<String, dynamic> json) =>
      _$ActivityFromJson(json);
}

4. 关键点总结

  1. ref.refresh(provider.future) 是核心: 它不仅触发重新获取数据,还返回一个 Future,让 RefreshIndicator 知道何时停止旋转。
  1. UI 渲染逻辑的健壮性: 使用 AsyncValue<Activity>(:final value?) 能够正确处理“正在刷新”的状态。Riverpod 的 AsyncValue 在刷新时会保留上一次的数据(isLoading: truevalue 不为空),我们可以利用这一点防止页面在刷新时闪烁回 Loading 状态。
  1. 代码生成: 使用 riverpod_generator 大幅减少了样板代码,使 Provider 的定义更加清晰易读。