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

Flutter多个ExpansionTile同步折叠问题求助

问题分析

你的核心问题是所有MapCard共享同一个MapCardViewModel实例——折叠一个ExpansionTile时,ViewModel里的全局状态被重置,导致所有关联组件同步响应。设置Key只能帮助Flutter区分组件实例,但无法解决状态共享的本质问题。

解决方案

下面提供两种可行修复方案,可根据项目架构选择:

方案一:将状态移至MapCard的State类(推荐,适合独立组件场景)

把原本放在ViewModel里的状态,迁移到_MapCardState中,让每个MapCard拥有独立状态:

class MapCard extends StatefulWidget {
  final String building;
  final int floor;
  const MapCard({super.key, required this.building, required this.floor});

  @override
  State<MapCard> createState() => _MapCardState();
}

class _MapCardState extends State<MapCard> {
  // 每个MapCard独立维护自身状态
  bool isFloorChosen = false;
  bool isAreaVisible = false;
  bool isAreaChosen = false;
  bool isButtonsVisible = false;
  int floorNumber = 1;
  String area = "";
  
  // 模拟数据,实际可从API获取
  final List<int> floors = [1,2,3,4];
  final List<String> areas = ["Area A", "Area B", "Area C"];

  @override
  void initState() {
    super.initState();
    floorNumber = floors[0];
    area = areas[0];
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Card(
        color: Colors.white,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
        child: SizedBox(
          width: MediaQuery.of(context).size.width * 0.9,
          child: Padding(
            padding: const EdgeInsets.only(bottom: 12),
            child: ExpansionTile(
              key: PageStorageKey(widget.building),
              onExpansionChanged: (changed) {
                if (!changed) {
                  setState(() {
                    isAreaVisible = false;
                    isButtonsVisible = false;
                    isFloorChosen = false;
                    floorNumber = floors[0];
                    area = areas[0];
                  });
                }
              },
              title: Row(
                children: [
                  Container(
                    padding: const EdgeInsets.only(top: 8, bottom: 8, right: 8),
                    child: Image.asset(
                      "assets/images/example.png",
                      height: 80,
                      width: 80,
                    ),
                  ),
                  Flexible(
                    child: Container(
                      padding: const EdgeInsets.fromLTRB(0, 8, 8, 8),
                      child: Column(
                        children: [
                          Text("${widget.building} Apartment \nFloor Count ${widget.floor} ")
                        ],
                      ),
                    ),
                  )
                ],
              ),
              children: [
                const Text("Choose Floor"),
                Padding(
                  padding: const EdgeInsets.only(right: 24, left: 24),
                  child: DropdownButton(
                    isExpanded: true,
                    value: isFloorChosen == false ? floors[0] : floorNumber,
                    items: floors.map<DropdownMenuItem<int>>((int i) {
                      return DropdownMenuItem<int>(
                        value: i,
                        child: Text(i.toString()),
                      );
                    }).toList(),
                    onChanged: (int? value) {
                      setState(() {
                        floorNumber = value!;
                        isFloorChosen = true;
                        isAreaVisible = true;
                      });
                    },
                  ),
                ),
                Visibility(
                  visible: isAreaVisible,
                  child: Column(
                    children: [
                      const Text("Choose an Area to map"),
                      Padding(
                        padding: const EdgeInsets.only(right: 24, left: 24),
                        child: DropdownButton(
                          isExpanded: true,
                          value: isAreaChosen == false ? areas[0] : area,
                          items: areas.map<DropdownMenuItem<String>>((String value) {
                            return DropdownMenuItem<String>(
                              value: value,
                              child: Text(value),
                            );
                          }).toList(),
                          onChanged: (String? value) {
                            setState(() {
                              area = value!;
                              isAreaChosen = true;
                              isButtonsVisible = true;
                            });
                          },
                        ),
                      ),
                    ],
                  ),
                ),
                Visibility(
                  visible: isButtonsVisible,
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      TextButton(
                        onPressed: () {
                          showDialog(
                            context: context,
                            builder: (BuildContext context) {
                              return CustomDialog(
                                title: "Mapping Status",
                                content: "This area hasn't been mapped yet",
                                page: Container(),
                                buttonColor: MainColors().mainBlue,
                              );
                            },
                          );
                        },
                        child: const Text("Show Area Map"),
                      ),
                      ElevatedButton(
                        onPressed: () {
                          Navigator.of(context).push(
                            MaterialPageRoute(
                              builder: (context) => const MappedPage(),
                            ),
                          );
                        },
                        style: ElevatedButton.styleFrom(backgroundColor: MainColors().mainBlue),
                        child: const Text("Map The Area", style: TextStyle(color: Colors.white)),
                      )
                    ],
                  ),
                )
              ],
            ),
          ),
        ),
      ),
    );
  }
}

方案二:修改ViewModel支持多实例状态隔离(适合需全局共享部分数据的场景)

若必须使用ViewModel,可让它通过Map结构为每个building维护独立状态:

修改后的ViewModel示例

class MapCardViewModel extends ChangeNotifier {
  // 以building名称为key,存储每个卡片的独立状态
  final Map<String, CardState> _cardStates = {};
  
  // 全局共享的列表数据
  final List<int> floors = [1,2,3,4];
  final List<String> areas = ["Area A", "Area B", "Area C"];

  // 获取指定building的状态,不存在则初始化
  CardState getCardState(String building) {
    if (!_cardStates.containsKey(building)) {
      _cardStates[building] = CardState(
        floorNumber: floors[0],
        area: areas[0],
        isFloorChosen: false,
        isAreaVisible: false,
        isAreaChosen: false,
        isButtonsVisible: false,
      );
    }
    return _cardStates[building]!;
  }

  // 重置指定building的状态
  void resetCardState(String building) {
    if (_cardStates.containsKey(building)) {
      _cardStates[building] = CardState(
        floorNumber: floors[0],
        area: areas[0],
        isFloorChosen: false,
        isAreaVisible: false,
        isAreaChosen: false,
        isButtonsVisible: false,
      );
      notifyListeners();
    }
  }

  // 更新楼层选择
  void setFloorNumber(String building, int value) {
    final state = getCardState(building);
    state.floorNumber = value;
    state.isFloorChosen = true;
    state.isAreaVisible = true;
    notifyListeners();
  }

  // 更新区域选择
  void setArea(String building, String value) {
    final state = getCardState(building);
    state.area = value;
    state.isAreaChosen = true;
    state.isButtonsVisible = true;
    notifyListeners();
  }
}

// 单独定义卡片状态类
class CardState {
  int floorNumber;
  String area;
  bool isFloorChosen;
  bool isAreaVisible;
  bool isAreaChosen;
  bool isButtonsVisible;

  CardState({
    required this.floorNumber,
    required this.area,
    required this.isFloorChosen,
    required this.isAreaVisible,
    required this.isAreaChosen,
    required this.isButtonsVisible,
  });
}

修改后的MapCard使用方式

class _MapCardState extends State<MapCard> {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Consumer<MapCardViewModel>(
        builder: (context, vm, child) {
          final cardState = vm.getCardState(widget.building);
          return Card(
            color: Colors.white,
            shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
            child: SizedBox(
              width: MediaQuery.of(context).size.width * 0.9,
              child: Padding(
                padding: const EdgeInsets.only(bottom: 12),
                child: ExpansionTile(
                  key: PageStorageKey(widget.building),
                  onExpansionChanged: (changed) {
                    if (!changed) vm.resetCardState(widget.building);
                  },
                  title: Row(
                    children: [
                      Container(
                        padding: const EdgeInsets.only(top: 8, bottom: 8, right: 8),
                        child: Image.asset(
                          "assets/images/example.png",
                          height: 80,
                          width: 80,
                        ),
                      ),
                      Flexible(
                        child: Container(
                          padding: const EdgeInsets.fromLTRB(0, 8, 8, 8),
                          child: Column(
                            children: [
                              Text("${widget.building} Apartment \nFloor Count ${widget.floor} ")
                            ],
                          ),
                        ),
                      )
                    ],
                  ),
                  children: [
                    const Text("Choose Floor"),
                    Padding(
                      padding: const EdgeInsets.only(right: 24, left: 24),
                      child: DropdownButton(
                        isExpanded: true,
                        value: cardState.isFloorChosen == false ? vm.floors[0] : cardState.floorNumber,
                        items: vm.floors.map<DropdownMenuItem<int>>((int i) {
                          return DropdownMenuItem<int>(
                            value: i,
                            child: Text(i.toString()),
                          );
                        }).toList(),
                        onChanged: (int? value) => vm.setFloorNumber(widget.building, value!),
                      ),
                    ),
                    Visibility(
                      visible: cardState.isAreaVisible,
                      child: Column(
                        children: [
                          const Text("Choose an Area to map"),
                          Padding(
                            padding: const EdgeInsets.only(right: 24, left: 24),
                            child: DropdownButton(
                              isExpanded: true,
                              value: cardState.isAreaChosen == false ? vm.areas[0] : cardState.area,
                              items: vm.areas.map<DropdownMenuItem<String>>((String value) {
                                return DropdownMenuItem<String>(
                                  value: value,
                                  child: Text(value),
                                );
                              }).toList(),
                              onChanged: (String? value) => vm.setArea(widget.building, value!),
                            ),
                          ),
                        ],
                      ),
                    ),
                    Visibility(
                      visible: cardState.isButtonsVisible,
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                        children: [
                          TextButton(
                            onPressed: () {
                              showDialog(
                                context: context,
                                builder: (BuildContext context) {
                                  return CustomDialog(
                                    title: "Mapping Status",
                                    content: "This area hasn't been mapped yet",
                                    page: Container(),
                                    buttonColor: MainColors().mainBlue,
                                  );
                                },
                              );
                            },
                            child: const Text("Show Area Map"),
                          ),
                          ElevatedButton(
                            onPressed: () {
                              Navigator.of(context).push(
                                MaterialPageRoute(
                                  builder: (context) => const MappedPage(),
                                ),
                              );
                            },
                            style: ElevatedButton.styleFrom(backgroundColor: MainColors().mainBlue),
                            child: const Text("Map The Area", style: TextStyle(color: Colors.white)),
                          )
                        ],
                      ),
                    )
                  ],
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}
关键说明
  • 方案一:每个MapCard完全独立,状态互不干扰,代码更简洁,适合组件无需全局共享状态的场景。
  • 方案二:通过ViewModel的Map结构隔离状态,适合需要在其他组件中访问卡片状态的场景。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.13 01:01:18