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

Flutter实现Card内自适应尺寸的GitHub风格热力图方块

Flutter Card内GitHub风格热力图自适应适配方案

问题背景

在Card组件内实现GitHub风格热力图时,UI适配存在卡点:核心需求是让热力图根据设备屏幕尺寸动态伸缩,完美适配其所在的Card容器。

实现效果示例截图:
GitHub风格热力图实现效果

现有核心实现逻辑:

  • 创建Column容器,顶部放置两行文本内容
  • 文本下方插入Row组件,Row的子元素为多个承载正方形方块的Column组件

目前待解决问题:无法确定适配逻辑应该作用于单个热力方块、承载方块的列容器,还是需要两者同时调整;多次尝试均触发无界约束错误,不清楚约束的添加位置与正确实现方式。初步猜测需要使用AspectRatio()组件包裹方块,以保持1:1的正方形比例。(为简化示例,已移除实际代码中冗余的业务逻辑)

现有问题代码

ProfileView 主组件

class ProfileView extends StatelessWidget {
  const ProfileView({Key? key}) : super(key: key);

  List<Widget> _heatMapColumnList() {
    final _columns = <Widget>[];
    final _startDate = DateTime.now().subtract(const Duration(days: 365));
    final _endDate = DateTime.now();
    final _dateDifference = _endDate.difference(_startDate).inDays;
for (var index = 0 - (_startDate.weekday % 7);
    index <= _endDate.difference(_startDate).inDays;
    index += 7) {
  //helper to change date by index
  final _firstDay = DateUtility.changeDay(_startDate, index);

  _columns.add(
    HeatMapColumn(
      startDate: _firstDay,
      endDate: index <= _dateDifference - 7
          ? DateUtility.changeDay(_startDate, index + 6)
          : _endDate,
      numDays: min(_endDate.difference(_firstDay).inDays + 1, 7),
    ),
  );
}
return _columns;
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12),
          child: Card(
            elevation: 1,
            child: Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  const Text('Some Title Text'),
                  const Text('More SubTitle Text'),
                  const SizedBox(height: 10),
                  Row(
                    children: <Widget>[
                      ..._heatMapColumnList(),
                    ],
// ... 省略剩余代码

HeatMapColumn 列组件

class HeatMapColumn extends StatelessWidget {
      HeatMapColumn({
        super.key,
        required this.startDate,
        required this.endDate,
        required this.numDays,
      })  : dayContainers = List.generate(
              numDays,
              (i) => HeatMapBox(
                date: DateUtility.changeDay(startDate, 1),
              ),
            ),
            emptySpace = (numDays != 7)
                ? List.generate(
                    7 - numDays,
                    (i) => const HeatMapBox(
                      date: null,
                    ),
                  )
                : [];
    
      final List<Widget> dayContainers;
      final List<Widget> emptySpace;
    
      final DateTime startDate;
      final DateTime endDate;
    
      final int numDays;
    
      @override
      Widget build(BuildContext context) {
        return Padding(
          padding: const EdgeInsets.symmetric(vertical: 8),
          child: Column(
            children: <Widget>[
              ...dayContainers,
              ...emptySpace,
            ],
// ... 省略剩余代码

HeatMapBox 热力方块组件

// 需要动态调整尺寸的热力方块
class HeatMapBox extends StatelessWidget {
  const HeatMapBox({
    required this.date,
    this.color,
    super.key,
  });

  final DateTime? date;
  final Color? color;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(1),
      child: SizedBox(
        child: Container(
// 待替换的硬编码尺寸
          height: 3,
          width: 3,
          decoration: const BoxDecoration(
            color: Colors.black12,
          ),
        ),
      ),
    );
  }
}

解决方案

触发无界约束的核心原因是Row和Column默认根据子组件固有尺寸布局,没有给子组件传递明确的宽度/高度约束,直接在子组件里用比例组件拿不到可参考的边界尺寸。适配仅需两步,不需要在单个方块上硬编码尺寸:

  1. 给承载热力图的Row外层加LayoutBuilder,拿到当前Card容器给热力图区域的可用宽度
  2. 计算单周列的宽度:总可用宽度 / 总列数(一年365天约等于53列),给每个HeatMapColumn套上SizedBox指定固定宽度,内部的HeatMapBox用AspectRatio(aspectRatio: 1)自动撑满列宽保持正方形,不需要写死宽高数值。

修改后的核心代码示例:

// 替换原有Row部分的代码
LayoutBuilder(
  builder: (context, constraints) {
    // 拿到热力图区域可用总宽度
    final totalWidth = constraints.maxWidth;
    // 计算总列数:365天/7 ≈ 53列,可根据自身日期计算逻辑调整
    const columnCount = 53;
    // 单列表格宽度,自动适配容器总宽
    final singleColumnWidth = totalWidth / columnCount;
    return Row(
      children: [
        for (final column in _heatMapColumnList())
          SizedBox(
            width: singleColumnWidth,
            child: column,
          )
      ],
    );
  },
)

// 修改HeatMapBox组件,去掉硬编码的3px宽高
class HeatMapBox extends StatelessWidget {
  const HeatMapBox({
    required this.date,
    this.color,
    super.key,
  });

  final DateTime? date;
  final Color? color;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(1),
      // 1:1比例保持正方形,自动撑满父组件传递的宽度
      child: AspectRatio(
        aspectRatio: 1,
        child: Container(
          decoration: BoxDecoration(
            color: color ?? Colors.black12,
            borderRadius: BorderRadius.circular(2),
          ),
        ),
      ),
    );
  }
}

如果需要控制热力图整体高度,只需要在LayoutBuilder外层套一个固定高度的SizedBox即可,列内的方块会自动按7个等分高度适配,不会出现溢出或者比例失调的问题。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.30 01:30:53