Flutter实现拼图形状图片裁剪及GridView.builder布局构建
实现Flutter拼图样式拖拽布局方案
一、核心实现逻辑
- 用
ClipPath自定义拼图形状的裁剪路径,实现凹凸边效果 - 复用已有的
Draggable/DragTarget逻辑,将文本部件替换为裁剪后的图片块 - 通过
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
相关产品推荐
相关产品推荐

