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

Flutter实现拼图形状图片裁剪及GridView.builder布局构建

实现Flutter拼图样式拖拽布局方案

一、核心实现逻辑

  1. 用ClipPath自定义拼图形状的裁剪路径,实现凹凸边效果
  2. 复用已有的Draggable/DragTarget逻辑,将文本部件替换为裁剪后的图片块
  3. 通过ListView.builder生成可拖拽的拼图块列表,GridView.builder生成目标填充网格

二、自定义拼图裁剪路径

实现支持四边凹凸控制的CustomClipper,适配不同位置的拼图块:

class PuzzleClipper extends CustomClipper<Path> {
  final bool isTopNotch;
  final bool isRightNotch;
  final bool isBottomNotch;
  final bool isLeftNotch;
  final double notchRadius;

  PuzzleClipper({
    required this.isTopNotch,
    required this.isRightNotch,
    required this.isBottomNotch,
    required this.isLeftNotch,
    this.notchRadius = 15.0,
  });

  @override
  Path getClip(Size size) {
    final path = Path();
    final halfNotch = notchRadius / 2;

    // 起点定位
    path.moveTo(isLeftNotch ? notchRadius : 0, 0);

    // 上边路径(处理顶部凹凸)
    if (isTopNotch) {
      path.lineTo(size.width / 2 - halfNotch, 0);
      path.arcToPoint(
        Offset(size.width / 2 + halfNotch, 0),
        radius: Radius.circular(notchRadius),
        clockwise: false,
      );
    }
    path.lineTo(size.width - (isRightNotch ? notchRadius : 0), 0);

    // 右边路径(处理右侧凹凸)
    if (isRightNotch) {
      path.lineTo(size.width, size.height / 2 - halfNotch);
      path.arcToPoint(
        Offset(size.width, size.height / 2 + halfNotch),
        radius: Radius.circular(notchRadius),
        clockwise: false,
      );
    }
    path.lineTo(size.width, size.height - (isBottomNotch ? notchRadius : 0));

    // 下边路径(处理底部凹凸)
    if (isBottomNotch) {
      path.lineTo(size.width / 2 + halfNotch, size.height);
      path.arcToPoint(
        Offset(size.width / 2 - halfNotch, size.height),
        radius: Radius.circular(notchRadius),
        clockwise: false,
      );
    }
    path.lineTo(isLeftNotch ? notchRadius : 0, size.height);

    // 左边路径(处理左侧凹凸)
    if (isLeftNotch) {
      path.lineTo(0, size.height / 2 + halfNotch);
      path.arcToPoint(
        Offset(0, size.height / 2 - halfNotch),
        radius: Radius.circular(notchRadius),
        clockwise: false,
      );
    }
    path.close();

    return path;
  }

  @override
  bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false;
}

三、封装可拖拽拼图块组件

将裁剪后的图片包装为Draggable组件,处理拖拽时的占位显示:

class PuzzlePiece extends StatelessWidget {
  final int index;
  final String imagePath;
  final Size pieceSize;
  final PuzzleClipper clipper;

  const PuzzlePiece({
    super.key,
    required this.index,
    required this.imagePath,
    required this.pieceSize,
    required this.clipper,
  });

  @override
  Widget build(BuildContext context) {
    return Draggable<int>(
      data: index,
      feedback: _buildPieceContent(),
      childWhenDragging: Container(
        width: pieceSize.width,
        height: pieceSize.height,
        child: ClipPath(
          clipper: clipper,
          child: Container(color: Colors.grey[300]),
        ),
      ),
      child: _buildPieceContent(),
    );
  }

  Widget _buildPieceContent() {
    return ClipPath(
      clipper: clipper,
      child: Image.asset(
        imagePath,
        width: pieceSize.width,
        height: pieceSize.height,
        fit: BoxFit.cover,
      ),
    );
  }
}

四、实现网格目标空位组件

用DragTarget实现网格空位,接收拖拽的拼图块并更新填充状态:

class PuzzleSlot extends StatelessWidget {
  final int index;
  final Size slotSize;
  final PuzzleClipper clipper;
  final int? filledIndex;
  final ValueChanged<int> onAccept;

  const PuzzleSlot({
    super.key,
    required this.index,
    required this.slotSize,
    required this.clipper,
    this.filledIndex,
    required this.onAccept,
  });

  @override
  Widget build(BuildContext context) {
    return DragTarget<int>(
      onAccept: (data) => onAccept(data),
      builder: (context, candidateData, rejectedData) {
        return ClipPath(
          clipper: clipper,
          child: Container(
            width: slotSize.width,
            height: slotSize.height,
            color: filledIndex != null ? Colors.transparent : Colors.grey[200],
            child: filledIndex != null
                ? Image.asset(
                    'assets/puzzle_$filledIndex.jpg',
                    fit: BoxFit.cover,
                  )
                : null,
          ),
        );
      },
    );
  }
}

五、整合列表与网格布局

在主页面中生成拼图块列表和目标网格,管理填充状态:

class PuzzlePage extends StatefulWidget {
  const PuzzlePage({super.key});

  @override
  State<PuzzlePage> createState() => _PuzzlePageState();
}

class _PuzzlePageState extends State<PuzzlePage> {
  final int rowCount = 3;
  final int columnCount = 3;
  late List<int?> filledSlots;
  late List<PuzzleClipper> pieceClippers;
  final Size pieceSize = const Size(100, 100);

  @override
  void initState() {
    super.initState();
    filledSlots = List.filled(rowCount * columnCount, null);
    pieceClippers = _generateClippers();
  }

  List<PuzzleClipper> _generateClippers() {
    final clippers = <PuzzleClipper>[];
    for (int row = 0; row < rowCount; row++) {
      for (int col = 0; col < columnCount; col++) {
        clippers.add(PuzzleClipper(
          isTopNotch: row != 0,
          isRightNotch: col != columnCount - 1,
          isBottomNotch: row != rowCount - 1,
          isLeftNotch: col != 0,
        ));
      }
    }
    return clippers;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          // 可拖拽拼图块列表
          SizedBox(
            width: pieceSize.width + 20,
            child: ListView.builder(
              padding: const EdgeInsets.all(10),
              itemCount: rowCount * columnCount,
              itemBuilder: (context, index) {
                return Padding(
                  padding: const EdgeInsets.only(bottom: 10),
                  child: PuzzlePiece(
                    index: index,
                    imagePath: 'assets/puzzle_$index.jpg',
                    pieceSize: pieceSize,
                    clipper: pieceClippers[index],
                  ),
                );
              },
            ),
          ),
          // 目标填充网格
          Expanded(
            child: GridView.builder(
              padding: const EdgeInsets.all(20),
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: columnCount,
                childAspectRatio: 1,
                crossAxisSpacing: 5,
                mainAxisSpacing: 5,
              ),
              itemCount: rowCount * columnCount,
              itemBuilder: (context, index) {
                return PuzzleSlot(
                  index: index,
                  slotSize: pieceSize,
                  clipper: pieceClippers[index],
                  filledIndex: filledSlots[index],
                  onAccept: (data) {
                    setState(() {
                      filledSlots[index] = data;
                    });
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

六、关键注意事项

  • 预先将原图分割为对应数量的小图,尺寸与pieceSize保持一致
  • 可调整notchRadius参数修改拼图凹凸边的大小,适配不同视觉需求
  • 可扩展拖拽逻辑:添加错误放置提示、拼图完成判断、重置功能等

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.16 04:10:35