You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter Riverpod问题:点击单张卡片致ListView所有卡片翻转

问题:Riverpod状态管理下点击单张卡片导致所有卡片翻转

背景

近期我开始学习Riverpod与Provider作为状态管理方案,想要重构现有应用。

问题描述

应用里用ListView.builder展示多张卡片,点击卡片(通过GestureDetector)需要实现翻转效果。用setState()时功能正常,但切换为Riverpod后,点击单张卡片会导致ListView中所有卡片一同翻转。

原代码

Provider

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

@immutable
class TestMovieCard {
  const TestMovieCard({
    required this.id,
    required this.title,
  });

  final String id;
  final String title;
}

class MoviesNotifier extends StateNotifier<List<TestMovieCard>> {
  MoviesNotifier()
      : super([
          const TestMovieCard(id: "1", title: "Im Westen nichts Neues"),
          const TestMovieCard(
              id: "2", title: "Star Wars Das Erwachen der Macht")
        ]);

  double _flipAngle = 0;
  double get flipAngle => _flipAngle;

  void flipMovieCard() {
    _flipAngle = (_flipAngle + pi) % (2 * pi);
    state = [...state];
  }

  bool checkIfCardIsFront(double animationValue) {
    if (animationValue >= (pi / 2)) {
      return false;
    } else {
      return true;
    }
  }
}

final movieCardProvider =
    StateNotifierProvider<MoviesNotifier, List<TestMovieCard>>((ref) {
  return MoviesNotifier();
});

Widget

class Test extends ConsumerWidget {
  const Test({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    Size size = MediaQuery.of(context).size;
    List<TestMovieCard> movieCards = ref.watch(movieCardProvider);

    return DefaultTabController(
      length: 2,
      child: Scaffold(
        body: Container(
            padding: const EdgeInsets.all(defaultPadding),
            child: Column(
              children: [
                SizedBox(
                  height: size.height * 0.05,
                ),
                Expanded(
                  child: SizedBox(
                      width: double.infinity,
                      child: TabBarView(children: [
                        MediaQuery.removePadding(
                            removeTop: true,
                            context: context,
                            child: ListView.builder(
                                itemCount: movieCards.length,
                                itemBuilder: (context, index) {
                                  return GestureDetector(
                                    // key: Key(movieCards[widget.listIndex].id),
                                    onTap: () {
                                      ref
                                          .watch(movieCardProvider.notifier)
                                          .flipMovieCard();
                                    },
                                    child: TweenAnimationBuilder(
                                        tween: Tween<double>(
                                            begin: 0,
                                            end: ref
                                                .watch(
                                                    movieCardProvider.notifier)
                                                .flipAngle),
                                        duration: const Duration(seconds: 1),
                                        builder: (BuildContext context,
                                            double val, __) {
                                          return (Transform(
                                              alignment: Alignment.center,
                                              transform: Matrix4.identity()
                                                ..setEntry(3, 2, 0.001)
                                                ..rotateY(val),
                                              child: ref
                                                      .watch(movieCardProvider
                                                          .notifier)
                                                      .checkIfCardIsFront(val)
                                                  ? Card(
                                                      elevation: 5,
                                                      shape: const RoundedRectangleBorder(
                                                          borderRadius:
                                                              BorderRadius.all(
                                                                  Radius.circular(
                                                                      20.0))),
                                                      child: Container(
                                                          height: size.height *
                                                              0.25,
                                                          decoration:
                                                              const BoxDecoration(
                                                            borderRadius:
                                                                BorderRadius.all(
                                                                    Radius.circular(
                                                                        20.0)),
                                                          ),
                                                          child: const Center(
                                                            child:
                                                                Text("Front"),
                                                          )))
                                                  : Transform(
                                                      alignment:
                                                          Alignment.center,
                                                      transform:
                                                          Matrix4.identity()
                                                            ..rotateY(pi),
                                                      child: Card(
                                                          elevation: 5,
                                                          shape: const RoundedRectangleBorder(
                                                              borderRadius: BorderRadius
                                                                  .all(Radius
                                                                      .circular(
                                                                          20.0))),
                                                          child: Container(
                                                            height:
                                                                size.height *
                                                                    0.25,
                                                            width:
                                                                double.infinity,
                                                            decoration:
                                                                const BoxDecoration(
                                                              borderRadius:
                                                                  BorderRadius
                                                                      .all(
                                                                Radius.circular(
                                                                    20.0),
                                                              ),
                                                            ),
                                                            child: const Center(
                                                              child:
                                                                  Text("Back"),
                                                            ),
                                                          )),
                                                    )));
                                        }),
                                  );
                                })),
                        Placeholder()
                      ])),
                )
              ],
            )),
        bottomNavigationBar: const CustomBottomNavigationBar(),
      ),
    );
  }
}

我已经尝试在多个位置使用Consumer,但所有列表项的状态仍会被重新加载。


尝试修改后的Provider代码(基于Axel的帮助)

@immutable
class TestMovieCard {
  TestMovieCard({
    required this.id,
    required this.title,
  });

  final String id;
  final String title;
  double flipAngle = 0;

  void flip() {
    flipAngle = (flipAngle + pi) % (2 * pi);
  }
}

class MoviesNotifier extends StateNotifier<List<TestMovieCard>> {
  MoviesNotifier()
      : super([
          TestMovieCard(id: "1", title: "Im Westen nichts Neues"),
          TestMovieCard(
            id: "2",
            title: "Star Wars Das Erwachen der Macht",
          )
        ]);

  void flipMovieCard(String idToFlip) {
    state = [
      ...state.where((x) => x.id != idToFlip),
      ...state.where((x) => x.id == idToFlip).flip()
    ];
  }

  bool checkIfCardIsFront(double animationValue) {
    if (animationValue >= (pi / 2)) {
      return false;
    } else {
      return true;
    }
  }
}

final movieCardProvider =
    StateNotifierProvider<MoviesNotifier, List<TestMovieCard>>((ref) {
  return MoviesNotifier();
});

问题分析与解决方案

问题核心在于所有卡片共享了同一个全局的_flipAngle状态,只要这个值变化,所有监听它的卡片都会同步更新。另外你尝试的修改还存在几个问题:

  1. TestMovieCard标注了@immutable但却有可变的flipAngle字段,破坏了不可变规则,导致Riverpod无法正确识别状态变化
  2. state.where((x) => x.id == idToFlip).flip()写法错误,where返回迭代器,不能直接调用flip()方法

正确实现思路

  1. 让TestMovieCard保持不可变性,每次翻转时创建新的实例
  2. 给每个卡片维护独立的翻转角度状态,而非全局共享一个
  3. 在flipMovieCard方法中,仅更新目标卡片的状态,其余卡片保持不变

修正后的Provider代码

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

@immutable
class TestMovieCard {
  const TestMovieCard({
    required this.id,
    required this.title,
    this.flipAngle = 0.0,
  });

  final String id;
  final String title;
  final double flipAngle;

  // 创建新实例实现状态更新,保持不可变性
  TestMovieCard copyWith({
    double? flipAngle,
  }) {
    return TestMovieCard(
      id: id,
      title: title,
      flipAngle: flipAngle ?? this.flipAngle,
    );
  }

  // 计算翻转后的角度并返回新实例
  TestMovieCard flip() {
    final newAngle = (flipAngle + pi) % (2 * pi);
    return copyWith(flipAngle: newAngle);
  }
}

class MoviesNotifier extends StateNotifier<List<TestMovieCard>> {
  MoviesNotifier()
      : super([
          const TestMovieCard(id: "1", title: "西线无战事"),
          const TestMovieCard(id: "2", title: "星球大战:原力觉醒")
        ]);

  void flipMovieCard(String idToFlip) {
    state = state.map((card) {
      if (card.id == idToFlip) {
        return card.flip();
      }
      return card;
    }).toList();
  }

  bool checkIfCardIsFront(double animationValue) {
    return animationValue < pi / 2;
  }
}

final movieCardProvider =
    StateNotifierProvider<MoviesNotifier, List<TestMovieCard>>((ref) {
  return MoviesNotifier();
});

修正后的Widget代码

主要修改点:

  • 每个卡片监听自身的flipAngle状态,而非全局状态
  • 点击时传入当前卡片的id,仅更新目标卡片
  • 使用Consumer包裹单个卡片,避免整个ListView重绘
class Test extends ConsumerWidget {
  const Test({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    Size size = MediaQuery.of(context).size;
    List<TestMovieCard> movieCards = ref.watch(movieCardProvider);

    return DefaultTabController(
      length: 2,
      child: Scaffold(
        body: Container(
            padding: const EdgeInsets.all(16),
            child: Column(
              children: [
                SizedBox(height: size.height * 0.05),
                Expanded(
                  child: SizedBox(
                      width: double.infinity,
                      child: TabBarView(children: [
                        MediaQuery.removePadding(
                            removeTop: true,
                            context: context,
                            child: ListView.builder(
                                itemCount: movieCards.length,
                                itemBuilder: (context, index) {
                                  final card = movieCards[index];
                                  return Consumer(
                                    builder: (context, ref, child) {
                                      final updatedCard = ref.watch(movieCardProvider).firstWhere((c) => c.id == card.id);
                                      return GestureDetector(
                                        key: Key(updatedCard.id),
                                        onTap: () {
                                          ref.read(movieCardProvider.notifier).flipMovieCard(updatedCard.id);
                                        },
                                        child: TweenAnimationBuilder(
                                            tween: Tween<double>(
                                                begin: 0,
                                                end: updatedCard.flipAngle),
                                            duration: const Duration(seconds: 1),
                                            builder: (BuildContext context,
                                                double val, __) {
                                              final isFront = ref.read(movieCardProvider.notifier).checkIfCardIsFront(val);
                                              return Transform(
                                                  alignment: Alignment.center,
                                                  transform: Matrix4.identity()
                                                    ..setEntry(3, 2, 0.001)
                                                    ..rotateY(val),
                                                  child: isFront
                                                      ? Card(
                                                          elevation: 5,
                                                          shape: const RoundedRectangleBorder(
                                                              borderRadius:
                                                                  BorderRadius.all(
                                                                      Radius.circular(
                                                                          20.0))),
                                                          child: Container(
                                                              height: size.height *
                                                                  0.25,
                                                              decoration:
                                                                  const BoxDecoration(
                                                                borderRadius:
                                                                    BorderRadius.all(
                                                                        Radius.circular(
                                                                            20.0)),
                                                              ),
                                                              child: Center(
                                                                child: Text(updatedCard.title),
                                                              )))
                                                      : Transform(
                                                          alignment:
                                                              Alignment.center,
                                                          transform:
                                                              Matrix4.identity()
                                                                ..rotateY(pi),
                                                          child: Card(
                                                              elevation: 5,
                                                              shape: const RoundedRectangleBorder(
                                                                  borderRadius: BorderRadius
                                                                      .all(Radius
                                                                          .circular(
                                                                              20.0))),
                                                              child: Container(
                                                                height:
                                                                    size.height *
                                                                        0.25,
                                                                width:
                                                                    double.infinity,
                                                                decoration:
                                                                    const BoxDecoration(
                                                                  borderRadius:
                                                                      BorderRadius
                                                                          .all(
                                                                    Radius.circular(
                                                                        20.0),
                                                                  ),
                                                                ),
                                                                child: const Center(
                                                                  child:
                                                                      Text("Back"),
                                                                ),
                                                              )),
                                                        ));
                                            }),
                                      );
                                    },
                                  );
                                })),
                        const Placeholder()
                      ])),
                )
              ],
            )),
        bottomNavigationBar: const CustomBottomNavigationBar(),
      ),
    );
  }
}

内容的提问来源于stack exchange,提问作者Prox

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.07 13:15:32