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状态,只要这个值变化,所有监听它的卡片都会同步更新。另外你尝试的修改还存在几个问题:
TestMovieCard标注了@immutable但却有可变的flipAngle字段,破坏了不可变规则,导致Riverpod无法正确识别状态变化state.where((x) => x.id == idToFlip).flip()写法错误,where返回迭代器,不能直接调用flip()方法
正确实现思路
- 让
TestMovieCard保持不可变性,每次翻转时创建新的实例 - 给每个卡片维护独立的翻转角度状态,而非全局共享一个
- 在
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
相关产品推荐
相关产品推荐

