进阶实战 Flutter for OpenHarmony:ClipPath 自定义裁剪系统 - 高级视觉效果实现

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net


一、ClipPath 系统架构深度解析

在现代移动应用开发中,裁剪效果是创建独特视觉形状和提升用户界面美观度的重要手段。Flutter 提供了强大的 ClipPath 组件,通过自定义路径实现各种复杂的裁剪效果。理解路径裁剪的底层原理,是掌握高级视觉效果的关键。

📱 1.1 ClipPath 核心概念

ClipPath 是 Flutter 中用于对子组件进行路径裁剪的 Widget。它通过 CustomClipper 定义裁剪路径,将子组件裁剪成任意形状,而不影响子组件的实际布局。

ClipPath 与其他裁剪组件的关系:

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    Flutter 裁剪组件体系                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                    ClipPath (路径裁剪)                   │    │
│  │  最灵活的裁剪方式,支持任意形状                           │    │
│  └─────────────────────────────────────────────────────────┘    │
│                              │                                   │
│              ┌───────────────┼───────────────┐                  │
│              ▼               ▼               ▼                  │
│  ┌───────────────┐ ┌───────────────┐ ┌───────────────┐          │
│  │  ClipRect     │ │  ClipRRect    │ │  ClipOval     │          │
│  │  矩形裁剪     │ │  圆角矩形裁剪 │ │  椭圆裁剪     │          │
│  └───────────────┘ └───────────────┘ └───────────────┘          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

ClipPath 核心属性:

属性 类型 说明 应用场景
clipper CustomClipper 裁剪器 定义裁剪路径
clipBehavior Clip 裁剪行为 控制裁剪方式
child Widget 子组件 被裁剪的内容

🔬 1.2 Path 路径绘制原理

Path 是 Flutter 中定义几何路径的核心类,通过一系列绘图命令(移动、连线、曲线等)构建复杂的形状。

Path 常用方法:

dart 复制代码
// 创建路径
final path = Path();

// 移动到指定点
path.moveTo(x, y);

// 绘制直线
path.lineTo(x, y);

// 绘制相对直线
path.relativeLineTo(dx, dy);

// 绘制二次贝塞尔曲线
path.quadraticBezierTo(x1, y1, x2, y2);

// 绘制三次贝塞尔曲线
path.cubicTo(x1, y1, x2, y2, x3, y3);

// 绘制圆弧
path.arcTo(rect, startAngle, sweepAngle, forceMoveTo);

// 绘制圆
path.addOval(rect);

// 绘制矩形
path.addRect(rect);

// 绘制圆角矩形
path.addRRect(rrect);

// 绘制多边形
path.addPolygon(points, close);

// 闭合路径
path.close();

路径组合操作:

dart 复制代码
// 路径相加(并集)
Path.combine(PathOperation.union, path1, path2);

// 路径相减(差集)
Path.combine(PathOperation.difference, path1, path2);

// 路径交集
Path.combine(PathOperation.intersect, path1, path2);

// 路径异或
Path.combine(PathOperation.xor, path1, path2);

// 反转路径
Path.combine(PathOperation.reverseDifference, path1, path2);

🎯 1.3 CustomClipper 工作原理

CustomClipper 是定义裁剪逻辑的核心抽象类,通过重写 getClipshouldReclip 方法实现自定义裁剪。

dart 复制代码
abstract class CustomClipper<T> {
  T getClip(Size size);           // 返回裁剪区域
  bool shouldReclip(covariant CustomClipper<T> oldClipper);  // 是否重新裁剪
}

CustomClipper 工作流程:

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                  CustomClipper 工作流程                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 组件构建                                                    │
│     │                                                          │
│     ▼                                                          │
│  2. 调用 getClip(size) 获取裁剪路径                             │
│     │                                                          │
│     ▼                                                          │
│  3. 应用裁剪路径到子组件                                        │
│     │                                                          │
│     ▼                                                          │
│  4. 状态更新时调用 shouldReclip() 判断是否重新裁剪              │
│     │                                                          │
│     ├── true  -> 重新调用 getClip()                            │
│     └── false -> 保持现有裁剪                                   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

二、基础裁剪形状实现

掌握了 ClipPath 的基本原理后,让我们通过实际的代码示例来学习各种裁剪效果的实现方法。

👆 2.1 三角形裁剪

三角形是最基础的裁剪形状,通过三个点连线闭合形成。

dart 复制代码
import 'dart:math';
import 'package:flutter/material.dart';

/// 三角形裁剪器
class TriangleClipper extends CustomClipper<Path> {
  final bool isUpward;

  TriangleClipper({this.isUpward = true});

  @override
  Path getClip(Size size) {
    final path = Path();
    
    if (isUpward) {
      path.moveTo(size.width / 2, 0);
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
    } else {
      path.moveTo(0, 0);
      path.lineTo(size.width, 0);
      path.lineTo(size.width / 2, size.height);
    }
    
    path.close();
    return path;
  }

  @override
  bool shouldReclip(TriangleClipper oldClipper) => isUpward != oldClipper.isUpward;
}

/// 三角形裁剪示例
class TriangleClipDemo extends StatelessWidget {
  const TriangleClipDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('三角形裁剪')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ClipPath(
              clipper: TriangleClipper(isUpward: true),
              child: Container(
                width: 200,
                height: 200,
                decoration: const BoxDecoration(
                  gradient: LinearGradient(
                    colors: [Colors.blue, Colors.cyan],
                    begin: Alignment.topCenter,
                    end: Alignment.bottomCenter,
                  ),
                ),
                child: const Center(
                  child: Text(
                    '向上三角形',
                    style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
                  ),
                ),
              ),
            ),
            const SizedBox(height: 40),
            ClipPath(
              clipper: TriangleClipper(isUpward: false),
              child: Container(
                width: 200,
                height: 200,
                decoration: const BoxDecoration(
                  gradient: LinearGradient(
                    colors: [Colors.purple, Colors.pink],
                    begin: Alignment.topCenter,
                    end: Alignment.bottomCenter,
                  ),
                ),
                child: const Center(
                  child: Padding(
                    padding: EdgeInsets.only(top: 80),
                    child: Text(
                      '向下三角形',
                      style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
                    ),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

🔧 2.2 多边形裁剪

多边形裁剪通过连接多个顶点形成封闭区域,可以创建各种正多边形效果。

dart 复制代码
/// 多边形裁剪器
class PolygonClipper extends CustomClipper<Path> {
  final int sides;

  PolygonClipper({required this.sides});

  @override
  Path getClip(Size size) {
    final path = Path();
    final center = Offset(size.width / 2, size.height / 2);
    final radius = min(size.width, size.height) / 2;
    
    for (int i = 0; i < sides; i++) {
      final angle = (2 * pi * i / sides) - pi / 2;
      final x = center.dx + radius * cos(angle);
      final y = center.dy + radius * sin(angle);
      
      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
    }
    
    path.close();
    return path;
  }

  @override
  bool shouldReclip(PolygonClipper oldClipper) => sides != oldClipper.sides;
}

/// 多边形裁剪示例
class PolygonClipDemo extends StatefulWidget {
  const PolygonClipDemo({super.key});

  @override
  State<PolygonClipDemo> createState() => _PolygonClipDemoState();
}

class _PolygonClipDemoState extends State<PolygonClipDemo> {
  int _sides = 6;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('多边形裁剪')),
      body: Column(
        children: [
          Expanded(
            child: Center(
              child: ClipPath(
                clipper: PolygonClipper(sides: _sides),
                child: Container(
                  width: 200,
                  height: 200,
                  decoration: BoxDecoration(
                    gradient: LinearGradient(
                      colors: [
                        Colors.primaries[_sides % Colors.primaries.length],
                        Colors.primaries[(_sides + 1) % Colors.primaries.length],
                      ],
                      begin: Alignment.topLeft,
                      end: Alignment.bottomRight,
                    ),
                  ),
                  child: Center(
                    child: Text(
                      '$_sides边形',
                      style: const TextStyle(
                        color: Colors.white,
                        fontSize: 24,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ),
          Container(
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(
              color: Colors.grey[100],
              borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
            ),
            child: Column(
              children: [
                Row(
                  children: [
                    const SizedBox(width: 80, child: Text('边数:')),
                    Expanded(
                      child: Slider(
                        value: _sides.toDouble(),
                        min: 3,
                        max: 12,
                        divisions: 9,
                        activeColor: Colors.teal,
                        onChanged: (value) => setState(() => _sides = value.round()),
                      ),
                    ),
                    SizedBox(width: 60, child: Text('$_sides边形')),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

🎨 2.3 星形裁剪

星形裁剪通过交替内外半径的点连接形成,可以创建各种星形效果。

dart 复制代码
/// 星形裁剪器
class StarClipper extends CustomClipper<Path> {
  final int points;
  final double innerRadiusRatio;

  StarClipper({required this.points, this.innerRadiusRatio = 0.5});

  @override
  Path getClip(Size size) {
    final path = Path();
    final center = Offset(size.width / 2, size.height / 2);
    final outerRadius = min(size.width, size.height) / 2;
    final innerRadius = outerRadius * innerRadiusRatio;
    
    for (int i = 0; i < points * 2; i++) {
      final radius = i.isEven ? outerRadius : innerRadius;
      final angle = (pi * i / points) - pi / 2;
      final x = center.dx + radius * cos(angle);
      final y = center.dy + radius * sin(angle);
      
      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
    }
    
    path.close();
    return path;
  }

  @override
  bool shouldReclip(StarClipper oldClipper) =>
      points != oldClipper.points || innerRadiusRatio != oldClipper.innerRadiusRatio;
}

/// 星形裁剪示例
class StarClipDemo extends StatefulWidget {
  const StarClipDemo({super.key});

  @override
  State<StarClipDemo> createState() => _StarClipDemoState();
}

class _StarClipDemoState extends State<StarClipDemo> {
  int _points = 5;
  double _innerRadius = 0.5;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('星形裁剪')),
      body: Column(
        children: [
          Expanded(
            child: Center(
              child: ClipPath(
                clipper: StarClipper(points: _points, innerRadiusRatio: _innerRadius),
                child: Container(
                  width: 200,
                  height: 200,
                  decoration: const BoxDecoration(
                    gradient: LinearGradient(
                      colors: [Colors.amber, Colors.orange],
                      begin: Alignment.topLeft,
                      end: Alignment.bottomRight,
                    ),
                  ),
                  child: Center(
                    child: Text(
                      '$_points角星',
                      style: const TextStyle(
                        color: Colors.white,
                        fontSize: 24,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ),
          Container(
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(
              color: Colors.grey[100],
              borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
            ),
            child: Column(
              children: [
                Row(
                  children: [
                    const SizedBox(width: 80, child: Text('角数:')),
                    Expanded(
                      child: Slider(
                        value: _points.toDouble(),
                        min: 3,
                        max: 12,
                        divisions: 9,
                        activeColor: Colors.amber,
                        onChanged: (value) => setState(() => _points = value.round()),
                      ),
                    ),
                    SizedBox(width: 60, child: Text('$_points角')),
                  ],
                ),
                Row(
                  children: [
                    const SizedBox(width: 80, child: Text('内径比:')),
                    Expanded(
                      child: Slider(
                        value: _innerRadius,
                        min: 0.1,
                        max: 0.9,
                        divisions: 16,
                        activeColor: Colors.orange,
                        onChanged: (value) => setState(() => _innerRadius = value),
                      ),
                    ),
                    SizedBox(width: 60, child: Text(_innerRadius.toStringAsFixed(2))),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

三、波浪形裁剪效果

波浪形裁剪是常见的装饰效果,可以创建优美的曲线边界。

🌊 3.1 正弦波浪裁剪

使用正弦函数创建平滑的波浪边界。

dart 复制代码
/// 波浪裁剪器
class WaveClipper extends CustomClipper<Path> {
  final double waveHeight;
  final double waveFrequency;
  final bool isTop;

  WaveClipper({
    this.waveHeight = 20,
    this.waveFrequency = 2,
    this.isTop = true,
  });

  @override
  Path getClip(Size size) {
    final path = Path();
    
    if (isTop) {
      path.moveTo(0, waveHeight);
      for (double i = 0; i <= size.width; i++) {
        final y = waveHeight + sin(i * waveFrequency * 2 * pi / size.width) * waveHeight;
        path.lineTo(i, y);
      }
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
    } else {
      path.moveTo(0, 0);
      path.lineTo(size.width, 0);
      for (double i = size.width; i >= 0; i--) {
        final y = size.height - waveHeight - sin(i * waveFrequency * 2 * pi / size.width) * waveHeight;
        path.lineTo(i, y);
      }
    }
    
    path.close();
    return path;
  }

  @override
  bool shouldReclip(WaveClipper oldClipper) =>
      waveHeight != oldClipper.waveHeight ||
      waveFrequency != oldClipper.waveFrequency ||
      isTop != oldClipper.isTop;
}

/// 波浪裁剪示例
class WaveClipDemo extends StatefulWidget {
  const WaveClipDemo({super.key});

  @override
  State<WaveClipDemo> createState() => _WaveClipDemoState();
}

class _WaveClipDemoState extends State<WaveClipDemo> {
  double _waveHeight = 20;
  double _waveFrequency = 2;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('波浪裁剪')),
      body: Column(
        children: [
          ClipPath(
            clipper: WaveClipper(waveHeight: _waveHeight, waveFrequency: _waveFrequency, isTop: true),
            child: Container(
              height: 200,
              decoration: const BoxDecoration(
                gradient: LinearGradient(
                  colors: [Colors.blue, Colors.cyan],
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                ),
              ),
              child: const Center(
                child: Text(
                  '顶部波浪',
                  style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
                ),
              ),
            ),
          ),
          Expanded(
            child: Container(
              color: Colors.grey[100],
              child: Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    const Text('内容区域', style: TextStyle(fontSize: 18, color: Colors.grey)),
                    const SizedBox(height: 20),
                    Container(
                      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
                      decoration: BoxDecoration(
                        color: Colors.blue.withOpacity(0.1),
                        borderRadius: BorderRadius.circular(20),
                      ),
                      child: const Text('波浪高度和频率可通过下方滑块调整'),
                    ),
                  ],
                ),
              ),
            ),
          ),
          ClipPath(
            clipper: WaveClipper(waveHeight: _waveHeight, waveFrequency: _waveFrequency, isTop: false),
            child: Container(
              height: 100,
              decoration: const BoxDecoration(
                gradient: LinearGradient(
                  colors: [Colors.teal, Colors.green],
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                ),
              ),
            ),
          ),
          Container(
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(
              color: Colors.grey[100],
              borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
            ),
            child: Column(
              children: [
                Row(
                  children: [
                    const SizedBox(width: 80, child: Text('波浪高度:')),
                    Expanded(
                      child: Slider(
                        value: _waveHeight,
                        min: 5,
                        max: 50,
                        divisions: 18,
                        activeColor: Colors.blue,
                        onChanged: (value) => setState(() => _waveHeight = value),
                      ),
                    ),
                    SizedBox(width: 60, child: Text('${_waveHeight.toStringAsFixed(0)}px')),
                  ],
                ),
                Row(
                  children: [
                    const SizedBox(width: 80, child: Text('波浪频率:')),
                    Expanded(
                      child: Slider(
                        value: _waveFrequency,
                        min: 1,
                        max: 5,
                        divisions: 8,
                        activeColor: Colors.teal,
                        onChanged: (value) => setState(() => _waveFrequency = value),
                      ),
                    ),
                    SizedBox(width: 60, child: Text('${_waveFrequency.toStringAsFixed(1)}')),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

💬 3.2 贝塞尔曲线波浪

使用贝塞尔曲线创建更平滑的波浪效果。

dart 复制代码
/// 贝塞尔波浪裁剪器
class BezierWaveClipper extends CustomClipper<Path> {
  final double waveHeight;
  final int waveCount;

  BezierWaveClipper({this.waveHeight = 30, this.waveCount = 2});

  @override
  Path getClip(Size size) {
    final path = Path();
    final waveWidth = size.width / waveCount;
    
    path.moveTo(0, size.height);
    path.lineTo(0, size.height - waveHeight);
    
    for (int i = 0; i < waveCount; i++) {
      final startX = i * waveWidth;
      final midX = startX + waveWidth / 2;
      final endX = startX + waveWidth;
      
      path.quadraticBezierTo(
        midX, size.height - waveHeight * 2,
        endX, size.height - waveHeight,
      );
    }
    
    path.lineTo(size.width, size.height);
    path.close();
    
    return path;
  }

  @override
  bool shouldReclip(BezierWaveClipper oldClipper) =>
      waveHeight != oldClipper.waveHeight || waveCount != oldClipper.waveCount;
}

四、复杂裁剪效果实现

🔐 4.1 圆角缺口裁剪

创建带有圆角缺口的形状,常用于优惠券、卡片等设计。

dart 复制代码
/// 圆角缺口裁剪器
class NotchedClipper extends CustomClipper<Path> {
  final double notchRadius;
  final double notchPosition;
  final bool isLeft;

  NotchedClipper({
    this.notchRadius = 20,
    this.notchPosition = 0.5,
    this.isLeft = true,
  });

  @override
  Path getClip(Size size) {
    final path = Path();
    final notchY = size.height * notchPosition;
    
    if (isLeft) {
      path.moveTo(0, 0);
      path.lineTo(0, notchY - notchRadius);
      path.arcToPoint(
        Offset(0, notchY + notchRadius),
        radius: Radius.circular(notchRadius),
        clockwise: false,
      );
      path.lineTo(0, size.height);
      path.lineTo(size.width, size.height);
      path.lineTo(size.width, 0);
    } else {
      path.moveTo(0, 0);
      path.lineTo(size.width, 0);
      path.lineTo(size.width, notchY - notchRadius);
      path.arcToPoint(
        Offset(size.width, notchY + notchRadius),
        radius: Radius.circular(notchRadius),
        clockwise: true,
      );
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
    }
    
    path.close();
    return path;
  }

  @override
  bool shouldReclip(NotchedClipper oldClipper) =>
      notchRadius != oldClipper.notchRadius ||
      notchPosition != oldClipper.notchPosition ||
      isLeft != oldClipper.isLeft;
}

/// 优惠券样式裁剪示例
class CouponClipDemo extends StatelessWidget {
  const CouponClipDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('优惠券裁剪')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ClipPath(
              clipper: NotchedClipper(isLeft: true, notchRadius: 25),
              child: Container(
                width: 320,
                height: 120,
                decoration: const BoxDecoration(
                  gradient: LinearGradient(
                    colors: [Colors.orange, Colors.deepOrange],
                    begin: Alignment.centerLeft,
                    end: Alignment.centerRight,
                  ),
                ),
                child: Row(
                  children: [
                    Container(
                      width: 100,
                      alignment: Alignment.center,
                      child: const Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Text('¥50', style: TextStyle(color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold)),
                          Text('优惠券', style: TextStyle(color: Colors.white70, fontSize: 12)),
                        ],
                      ),
                    ),
                    Container(
                      width: 1,
                      height: 60,
                      color: Colors.white.withOpacity(0.3),
                    ),
                    Expanded(
                      child: Padding(
                        padding: const EdgeInsets.all(16),
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.center,
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            const Text('满100元可用', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
                            const SizedBox(height: 4),
                            Text('有效期至 2024-12-31', style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 12)),
                          ],
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 30),
            ClipPath(
              clipper: DoubleNotchedClipper(notchRadius: 20),
              child: Container(
                width: 320,
                height: 120,
                decoration: const BoxDecoration(
                  gradient: LinearGradient(
                    colors: [Colors.purple, Colors.pink],
                    begin: Alignment.centerLeft,
                    end: Alignment.centerRight,
                  ),
                ),
                child: Row(
                  children: [
                    Container(
                      width: 100,
                      alignment: Alignment.center,
                      child: const Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Text('¥100', style: TextStyle(color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold)),
                          Text('折扣券', style: TextStyle(color: Colors.white70, fontSize: 12)),
                        ],
                      ),
                    ),
                    Container(
                      width: 1,
                      height: 60,
                      color: Colors.white.withOpacity(0.3),
                    ),
                    Expanded(
                      child: Padding(
                        padding: const EdgeInsets.all(16),
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.center,
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            const Text('全场通用', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
                            const SizedBox(height: 4),
                            Text('有效期至 2024-12-31', style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 12)),
                          ],
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// 双缺口裁剪器
class DoubleNotchedClipper extends CustomClipper<Path> {
  final double notchRadius;

  DoubleNotchedClipper({this.notchRadius = 20});

  @override
  Path getClip(Size size) {
    final path = Path();
    final centerY = size.height / 2;
    
    path.moveTo(0, 0);
    path.lineTo(0, centerY - notchRadius);
    path.arcToPoint(
      Offset(0, centerY + notchRadius),
      radius: Radius.circular(notchRadius),
      clockwise: false,
    );
    path.lineTo(0, size.height);
    path.lineTo(size.width, size.height);
    path.lineTo(size.width, centerY + notchRadius);
    path.arcToPoint(
      Offset(size.width, centerY - notchRadius),
      radius: Radius.circular(notchRadius),
      clockwise: false,
    );
    path.lineTo(size.width, 0);
    
    path.close();
    return path;
  }

  @override
  bool shouldReclip(DoubleNotchedClipper oldClipper) =>
      notchRadius != oldClipper.notchRadius;
}

🎵 4.2 对角线裁剪

创建对角线切割效果,常用于卡片和背景装饰。

dart 复制代码
/// 对角线裁剪器
class DiagonalClipper extends CustomClipper<Path> {
  final double clipHeight;
  final bool isTopLeft;

  DiagonalClipper({this.clipHeight = 50, this.isTopLeft = true});

  @override
  Path getClip(Size size) {
    final path = Path();
    
    if (isTopLeft) {
      path.moveTo(0, clipHeight);
      path.lineTo(size.width, 0);
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
    } else {
      path.moveTo(0, 0);
      path.lineTo(size.width, clipHeight);
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
    }
    
    path.close();
    return path;
  }

  @override
  bool shouldReclip(DiagonalClipper oldClipper) =>
      clipHeight != oldClipper.clipHeight || isTopLeft != oldClipper.isTopLeft;
}

/// 对角线裁剪示例
class DiagonalClipDemo extends StatelessWidget {
  const DiagonalClipDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('对角线裁剪')),
      body: Column(
        children: [
          ClipPath(
            clipper: DiagonalClipper(clipHeight: 80, isTopLeft: true),
            child: Container(
              height: 200,
              decoration: const BoxDecoration(
                gradient: LinearGradient(
                  colors: [Colors.indigo, Colors.purple],
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                ),
              ),
              child: const Center(
                child: Padding(
                  padding: EdgeInsets.only(top: 50),
                  child: Text(
                    '对角线裁剪效果',
                    style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
                  ),
                ),
              ),
            ),
          ),
          Expanded(
            child: Center(
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  ClipPath(
                    clipper: DiagonalClipper(clipHeight: 40, isTopLeft: true),
                    child: Container(
                      width: 150,
                      height: 100,
                      color: Colors.teal,
                      child: const Center(
                        child: Padding(
                          padding: EdgeInsets.only(top: 20),
                          child: Text('卡片1', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
                        ),
                      ),
                    ),
                  ),
                  ClipPath(
                    clipper: DiagonalClipper(clipHeight: 40, isTopLeft: false),
                    child: Container(
                      width: 150,
                      height: 100,
                      color: Colors.orange,
                      child: const Center(
                        child: Text('卡片2', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

五、动画裁剪效果

🎬 5.1 揭开动画裁剪

通过动画控制裁剪区域,创建揭开效果。

dart 复制代码
/// 揭开动画裁剪器
class RevealClipper extends CustomClipper<Path> {
  final double revealFactor;
  final Axis direction;

  RevealClipper({required this.revealFactor, this.direction = Axis.horizontal});

  @override
  Path getClip(Size size) {
    final path = Path();
    
    if (direction == Axis.horizontal) {
      final revealWidth = size.width * revealFactor;
      path.addRect(Rect.fromLTWH(0, 0, revealWidth, size.height));
    } else {
      final revealHeight = size.height * revealFactor;
      path.addRect(Rect.fromLTWH(0, 0, size.width, revealHeight));
    }
    
    return path;
  }

  @override
  bool shouldReclip(RevealClipper oldClipper) =>
      revealFactor != oldClipper.revealFactor || direction != oldClipper.direction;
}

/// 揭开动画示例
class RevealClipDemo extends StatefulWidget {
  const RevealClipDemo({super.key});

  @override
  State<RevealClipDemo> createState() => _RevealClipDemoState();
}

class _RevealClipDemoState extends State<RevealClipDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    );
    _animation = Tween<double>(begin: 0, end: 1).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('揭开动画裁剪')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            AnimatedBuilder(
              animation: _animation,
              builder: (context, child) {
                return ClipPath(
                  clipper: RevealClipper(revealFactor: _animation.value),
                  child: Container(
                    width: 300,
                    height: 200,
                    decoration: const BoxDecoration(
                      gradient: LinearGradient(
                        colors: [Colors.blue, Colors.purple],
                        begin: Alignment.centerLeft,
                        end: Alignment.centerRight,
                      ),
                    ),
                    child: const Center(
                      child: Text(
                        '揭开效果',
                        style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
                      ),
                    ),
                  ),
                );
              },
            ),
            const SizedBox(height: 40),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () => _controller.forward(),
                  child: const Text('播放'),
                ),
                const SizedBox(width: 16),
                ElevatedButton(
                  onPressed: () => _controller.reverse(),
                  child: const Text('反向'),
                ),
                const SizedBox(width: 16),
                ElevatedButton(
                  onPressed: () => _controller.reset(),
                  child: const Text('重置'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

🎭 5.2 圆形扩散裁剪

从中心点向外扩散的圆形裁剪效果。

dart 复制代码
/// 圆形扩散裁剪器
class CircleRevealClipper extends CustomClipper<Path> {
  final double radiusFactor;
  final Offset center;

  CircleRevealClipper({required this.radiusFactor, required this.center});

  @override
  Path getClip(Size size) {
    final maxRadius = sqrt(pow(size.width, 2) + pow(size.height, 2));
    final radius = maxRadius * radiusFactor;
    
    return Path()
      ..addOval(Rect.fromCircle(center: center, radius: radius));
  }

  @override
  bool shouldReclip(CircleRevealClipper oldClipper) =>
      radiusFactor != oldClipper.radiusFactor || center != oldClipper.center;
}

/// 圆形扩散示例
class CircleRevealDemo extends StatefulWidget {
  const CircleRevealDemo({super.key});

  @override
  State<CircleRevealDemo> createState() => _CircleRevealDemoState();
}

class _CircleRevealDemoState extends State<CircleRevealDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  Offset _center = Offset.zero;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 800),
      vsync: this,
    );
    _animation = Tween<double>(begin: 0, end: 1).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _startAnimation(Offset position) {
    setState(() => _center = position);
    _controller.forward(from: 0);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('圆形扩散裁剪')),
      body: GestureDetector(
        onTapDown: (details) => _startAnimation(details.localPosition),
        child: AnimatedBuilder(
          animation: _animation,
          builder: (context, child) {
            return ClipPath(
              clipper: CircleRevealClipper(
                radiusFactor: _animation.value,
                center: _center,
              ),
              child: Container(
                width: double.infinity,
                height: double.infinity,
                decoration: const BoxDecoration(
                  gradient: LinearGradient(
                    colors: [Colors.teal, Colors.cyan],
                    begin: Alignment.topLeft,
                    end: Alignment.bottomRight,
                  ),
                ),
                child: Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      const Icon(Icons.touch_app, color: Colors.white, size: 48),
                      const SizedBox(height: 16),
                      Text(
                        '点击任意位置',
                        style: TextStyle(
                          color: Colors.white.withOpacity(0.9),
                          fontSize: 20,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            );
          },
        ),
      ),
    );
  }
}

六、完整代码示例

以下是本文所有示例的完整代码,可以直接运行体验:

dart 复制代码
import 'dart:math';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const ClipPathHomePage(),
    );
  }
}

class ClipPathHomePage extends StatelessWidget {
  const ClipPathHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('✂️ ClipPath 自定义裁剪系统')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          _buildSectionCard(context, title: '三角形裁剪', description: '基础三角形形状', icon: Icons.change_history, color: Colors.blue, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const TriangleClipDemo()))),
          _buildSectionCard(context, title: '多边形裁剪', description: '可调边数多边形', icon: Icons.hexagon, color: Colors.purple, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const PolygonClipDemo()))),
          _buildSectionCard(context, title: '星形裁剪', description: '可调角数星形', icon: Icons.star, color: Colors.amber, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const StarClipDemo()))),
          _buildSectionCard(context, title: '波浪裁剪', description: '正弦波浪边界', icon: Icons.waves, color: Colors.cyan, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WaveClipDemo()))),
          _buildSectionCard(context, title: '优惠券裁剪', description: '圆角缺口形状', icon: Icons.confirmation_number, color: Colors.orange, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CouponClipDemo()))),
          _buildSectionCard(context, title: '对角线裁剪', description: '斜切效果', icon: Icons.call_split, color: Colors.indigo, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const DiagonalClipDemo()))),
          _buildSectionCard(context, title: '揭开动画裁剪', description: '动画揭开效果', icon: Icons.animation, color: Colors.pink, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const RevealClipDemo()))),
          _buildSectionCard(context, title: '圆形扩散裁剪', description: '点击扩散效果', icon: Icons.circle, color: Colors.teal, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CircleRevealDemo()))),
        ],
      ),
    );
  }

  Widget _buildSectionCard(BuildContext context, {required String title, required String description, required IconData icon, required Color color, required VoidCallback onTap}) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(12),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            children: [
              Container(width: 56, height: 56, decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: color, size: 28)),
              const SizedBox(width: 16),
              Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 4), Text(description, style: TextStyle(fontSize: 13, color: Colors.grey[600]))])),
              Icon(Icons.chevron_right, color: Colors.grey[400]),
            ],
          ),
        ),
      ),
    );
  }
}

class TriangleClipper extends CustomClipper<Path> {
  final bool isUpward;
  TriangleClipper({this.isUpward = true});

  @override
  Path getClip(Size size) {
    final path = Path();
    if (isUpward) {
      path.moveTo(size.width / 2, 0);
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
    } else {
      path.moveTo(0, 0);
      path.lineTo(size.width, 0);
      path.lineTo(size.width / 2, size.height);
    }
    path.close();
    return path;
  }

  @override
  bool shouldReclip(TriangleClipper oldClipper) => isUpward != oldClipper.isUpward;
}

class TriangleClipDemo extends StatelessWidget {
  const TriangleClipDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('三角形裁剪')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ClipPath(
              clipper: TriangleClipper(isUpward: true),
              child: Container(
                width: 200, height: 200,
                decoration: const BoxDecoration(gradient: LinearGradient(colors: [Colors.blue, Colors.cyan], begin: Alignment.topCenter, end: Alignment.bottomCenter)),
                child: const Center(child: Text('向上三角形', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
              ),
            ),
            const SizedBox(height: 40),
            ClipPath(
              clipper: TriangleClipper(isUpward: false),
              child: Container(
                width: 200, height: 200,
                decoration: const BoxDecoration(gradient: LinearGradient(colors: [Colors.purple, Colors.pink], begin: Alignment.topCenter, end: Alignment.bottomCenter)),
                child: const Center(child: Padding(padding: EdgeInsets.only(top: 80), child: Text('向下三角形', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)))),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class PolygonClipper extends CustomClipper<Path> {
  final int sides;
  PolygonClipper({required this.sides});

  @override
  Path getClip(Size size) {
    final path = Path();
    final center = Offset(size.width / 2, size.height / 2);
    final radius = min(size.width, size.height) / 2;
    for (int i = 0; i < sides; i++) {
      final angle = (2 * pi * i / sides) - pi / 2;
      final x = center.dx + radius * cos(angle);
      final y = center.dy + radius * sin(angle);
      if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); }
    }
    path.close();
    return path;
  }

  @override
  bool shouldReclip(PolygonClipper oldClipper) => sides != oldClipper.sides;
}

class PolygonClipDemo extends StatefulWidget {
  const PolygonClipDemo({super.key});
  @override
  State<PolygonClipDemo> createState() => _PolygonClipDemoState();
}

class _PolygonClipDemoState extends State<PolygonClipDemo> {
  int _sides = 6;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('多边形裁剪')),
      body: Column(
        children: [
          Expanded(
            child: Center(
              child: ClipPath(
                clipper: PolygonClipper(sides: _sides),
                child: Container(
                  width: 200, height: 200,
                  decoration: BoxDecoration(gradient: LinearGradient(colors: [Colors.primaries[_sides % Colors.primaries.length], Colors.primaries[(_sides + 1) % Colors.primaries.length]], begin: Alignment.topLeft, end: Alignment.bottomRight)),
                  child: Center(child: Text('$_sides边形', style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold))),
                ),
              ),
            ),
          ),
          Container(
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(color: Colors.grey[100], borderRadius: const BorderRadius.vertical(top: Radius.circular(20))),
            child: Row(children: [const SizedBox(width: 80, child: Text('边数:')), Expanded(child: Slider(value: _sides.toDouble(), min: 3, max: 12, divisions: 9, activeColor: Colors.teal, onChanged: (value) => setState(() => _sides = value.round()))), SizedBox(width: 60, child: Text('$_sides边形'))]),
          ),
        ],
      ),
    );
  }
}

class StarClipper extends CustomClipper<Path> {
  final int points;
  final double innerRadiusRatio;
  StarClipper({required this.points, this.innerRadiusRatio = 0.5});

  @override
  Path getClip(Size size) {
    final path = Path();
    final center = Offset(size.width / 2, size.height / 2);
    final outerRadius = min(size.width, size.height) / 2;
    final innerRadius = outerRadius * innerRadiusRatio;
    for (int i = 0; i < points * 2; i++) {
      final radius = i.isEven ? outerRadius : innerRadius;
      final angle = (pi * i / points) - pi / 2;
      final x = center.dx + radius * cos(angle);
      final y = center.dy + radius * sin(angle);
      if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); }
    }
    path.close();
    return path;
  }

  @override
  bool shouldReclip(StarClipper oldClipper) => points != oldClipper.points || innerRadiusRatio != oldClipper.innerRadiusRatio;
}

class StarClipDemo extends StatefulWidget {
  const StarClipDemo({super.key});
  @override
  State<StarClipDemo> createState() => _StarClipDemoState();
}

class _StarClipDemoState extends State<StarClipDemo> {
  int _points = 5;
  double _innerRadius = 0.5;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('星形裁剪')),
      body: Column(
        children: [
          Expanded(
            child: Center(
              child: ClipPath(
                clipper: StarClipper(points: _points, innerRadiusRatio: _innerRadius),
                child: Container(
                  width: 200, height: 200,
                  decoration: const BoxDecoration(gradient: LinearGradient(colors: [Colors.amber, Colors.orange], begin: Alignment.topLeft, end: Alignment.bottomRight)),
                  child: Center(child: Text('$_points角星', style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold))),
                ),
              ),
            ),
          ),
          Container(
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(color: Colors.grey[100], borderRadius: const BorderRadius.vertical(top: Radius.circular(20))),
            child: Column(
              children: [
                Row(children: [const SizedBox(width: 80, child: Text('角数:')), Expanded(child: Slider(value: _points.toDouble(), min: 3, max: 12, divisions: 9, activeColor: Colors.amber, onChanged: (value) => setState(() => _points = value.round()))), SizedBox(width: 60, child: Text('$_points角'))]),
                Row(children: [const SizedBox(width: 80, child: Text('内径比:')), Expanded(child: Slider(value: _innerRadius, min: 0.1, max: 0.9, divisions: 16, activeColor: Colors.orange, onChanged: (value) => setState(() => _innerRadius = value))), SizedBox(width: 60, child: Text(_innerRadius.toStringAsFixed(2)))]),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class WaveClipper extends CustomClipper<Path> {
  final double waveHeight;
  final double waveFrequency;
  final bool isTop;
  WaveClipper({this.waveHeight = 20, this.waveFrequency = 2, this.isTop = true});

  @override
  Path getClip(Size size) {
    final path = Path();
    if (isTop) {
      path.moveTo(0, waveHeight);
      for (double i = 0; i <= size.width; i++) {
        final y = waveHeight + sin(i * waveFrequency * 2 * pi / size.width) * waveHeight;
        path.lineTo(i, y);
      }
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
    } else {
      path.moveTo(0, 0);
      path.lineTo(size.width, 0);
      for (double i = size.width; i >= 0; i--) {
        final y = size.height - waveHeight - sin(i * waveFrequency * 2 * pi / size.width) * waveHeight;
        path.lineTo(i, y);
      }
    }
    path.close();
    return path;
  }

  @override
  bool shouldReclip(WaveClipper oldClipper) => waveHeight != oldClipper.waveHeight || waveFrequency != oldClipper.waveFrequency || isTop != oldClipper.isTop;
}

class WaveClipDemo extends StatefulWidget {
  const WaveClipDemo({super.key});
  @override
  State<WaveClipDemo> createState() => _WaveClipDemoState();
}

class _WaveClipDemoState extends State<WaveClipDemo> {
  double _waveHeight = 20;
  double _waveFrequency = 2;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('波浪裁剪')),
      body: Column(
        children: [
          ClipPath(
            clipper: WaveClipper(waveHeight: _waveHeight, waveFrequency: _waveFrequency, isTop: true),
            child: Container(
              height: 200,
              decoration: const BoxDecoration(gradient: LinearGradient(colors: [Colors.blue, Colors.cyan], begin: Alignment.topLeft, end: Alignment.bottomRight)),
              child: const Center(child: Text('顶部波浪', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold))),
            ),
          ),
          Expanded(
            child: Container(
              color: Colors.grey[100],
              child: Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    const Text('内容区域', style: TextStyle(fontSize: 18, color: Colors.grey)),
                    const SizedBox(height: 20),
                    Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), decoration: BoxDecoration(color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(20)), child: const Text('波浪高度和频率可通过下方滑块调整')),
                  ],
                ),
              ),
            ),
          ),
          ClipPath(
            clipper: WaveClipper(waveHeight: _waveHeight, waveFrequency: _waveFrequency, isTop: false),
            child: Container(height: 100, decoration: const BoxDecoration(gradient: LinearGradient(colors: [Colors.teal, Colors.green], begin: Alignment.topLeft, end: Alignment.bottomRight))),
          ),
          Container(
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(color: Colors.grey[100], borderRadius: const BorderRadius.vertical(top: Radius.circular(20))),
            child: Column(
              children: [
                Row(children: [const SizedBox(width: 80, child: Text('波浪高度:')), Expanded(child: Slider(value: _waveHeight, min: 5, max: 50, divisions: 18, activeColor: Colors.blue, onChanged: (value) => setState(() => _waveHeight = value))), SizedBox(width: 60, child: Text('${_waveHeight.toStringAsFixed(0)}px'))]),
                Row(children: [const SizedBox(width: 80, child: Text('波浪频率:')), Expanded(child: Slider(value: _waveFrequency, min: 1, max: 5, divisions: 8, activeColor: Colors.teal, onChanged: (value) => setState(() => _waveFrequency = value))), SizedBox(width: 60, child: Text('${_waveFrequency.toStringAsFixed(1)}'))]),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class NotchedClipper extends CustomClipper<Path> {
  final double notchRadius;
  final double notchPosition;
  final bool isLeft;
  NotchedClipper({this.notchRadius = 20, this.notchPosition = 0.5, this.isLeft = true});

  @override
  Path getClip(Size size) {
    final path = Path();
    final notchY = size.height * notchPosition;
    if (isLeft) {
      path.moveTo(0, 0);
      path.lineTo(0, notchY - notchRadius);
      path.arcToPoint(Offset(0, notchY + notchRadius), radius: Radius.circular(notchRadius), clockwise: false);
      path.lineTo(0, size.height);
      path.lineTo(size.width, size.height);
      path.lineTo(size.width, 0);
    } else {
      path.moveTo(0, 0);
      path.lineTo(size.width, 0);
      path.lineTo(size.width, notchY - notchRadius);
      path.arcToPoint(Offset(size.width, notchY + notchRadius), radius: Radius.circular(notchRadius), clockwise: true);
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
    }
    path.close();
    return path;
  }

  @override
  bool shouldReclip(NotchedClipper oldClipper) => notchRadius != oldClipper.notchRadius || notchPosition != oldClipper.notchPosition || isLeft != oldClipper.isLeft;
}

class DoubleNotchedClipper extends CustomClipper<Path> {
  final double notchRadius;
  DoubleNotchedClipper({this.notchRadius = 20});

  @override
  Path getClip(Size size) {
    final path = Path();
    final centerY = size.height / 2;
    path.moveTo(0, 0);
    path.lineTo(0, centerY - notchRadius);
    path.arcToPoint(Offset(0, centerY + notchRadius), radius: Radius.circular(notchRadius), clockwise: false);
    path.lineTo(0, size.height);
    path.lineTo(size.width, size.height);
    path.lineTo(size.width, centerY + notchRadius);
    path.arcToPoint(Offset(size.width, centerY - notchRadius), radius: Radius.circular(notchRadius), clockwise: false);
    path.lineTo(size.width, 0);
    path.close();
    return path;
  }

  @override
  bool shouldReclip(DoubleNotchedClipper oldClipper) => notchRadius != oldClipper.notchRadius;
}

class CouponClipDemo extends StatelessWidget {
  const CouponClipDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('优惠券裁剪')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ClipPath(
              clipper: NotchedClipper(isLeft: true, notchRadius: 25),
              child: Container(
                width: 320, height: 120,
                decoration: const BoxDecoration(gradient: LinearGradient(colors: [Colors.orange, Colors.deepOrange], begin: Alignment.centerLeft, end: Alignment.centerRight)),
                child: Row(
                  children: [
                    Container(width: 100, alignment: Alignment.center, child: const Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text('¥50', style: TextStyle(color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold)), Text('优惠券', style: TextStyle(color: Colors.white70, fontSize: 12))])),
                    Container(width: 1, height: 60, color: Colors.white.withOpacity(0.3)),
                    Expanded(child: Padding(padding: const EdgeInsets.all(16), child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('满100元可用', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 4), Text('有效期至 2024-12-31', style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 12))]))),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 30),
            ClipPath(
              clipper: DoubleNotchedClipper(notchRadius: 20),
              child: Container(
                width: 320, height: 120,
                decoration: const BoxDecoration(gradient: LinearGradient(colors: [Colors.purple, Colors.pink], begin: Alignment.centerLeft, end: Alignment.centerRight)),
                child: Row(
                  children: [
                    Container(width: 100, alignment: Alignment.center, child: const Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text('¥100', style: TextStyle(color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold)), Text('折扣券', style: TextStyle(color: Colors.white70, fontSize: 12))])),
                    Container(width: 1, height: 60, color: Colors.white.withOpacity(0.3)),
                    Expanded(child: Padding(padding: const EdgeInsets.all(16), child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('全场通用', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 4), Text('有效期至 2024-12-31', style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 12))]))),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class DiagonalClipper extends CustomClipper<Path> {
  final double clipHeight;
  final bool isTopLeft;
  DiagonalClipper({this.clipHeight = 50, this.isTopLeft = true});

  @override
  Path getClip(Size size) {
    final path = Path();
    if (isTopLeft) {
      path.moveTo(0, clipHeight);
      path.lineTo(size.width, 0);
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
    } else {
      path.moveTo(0, 0);
      path.lineTo(size.width, clipHeight);
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
    }
    path.close();
    return path;
  }

  @override
  bool shouldReclip(DiagonalClipper oldClipper) => clipHeight != oldClipper.clipHeight || isTopLeft != oldClipper.isTopLeft;
}

class DiagonalClipDemo extends StatelessWidget {
  const DiagonalClipDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('对角线裁剪')),
      body: Column(
        children: [
          ClipPath(
            clipper: DiagonalClipper(clipHeight: 80, isTopLeft: true),
            child: Container(
              height: 200,
              decoration: const BoxDecoration(gradient: LinearGradient(colors: [Colors.indigo, Colors.purple], begin: Alignment.topLeft, end: Alignment.bottomRight)),
              child: const Center(child: Padding(padding: EdgeInsets.only(top: 50), child: Text('对角线裁剪效果', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold)))),
            ),
          ),
          Expanded(
            child: Center(
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  ClipPath(
                    clipper: DiagonalClipper(clipHeight: 40, isTopLeft: true),
                    child: Container(width: 150, height: 100, color: Colors.teal, child: const Center(child: Padding(padding: EdgeInsets.only(top: 20), child: Text('卡片1', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold))))),
                  ),
                  ClipPath(
                    clipper: DiagonalClipper(clipHeight: 40, isTopLeft: false),
                    child: Container(width: 150, height: 100, color: Colors.orange, child: const Center(child: Text('卡片2', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)))),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class RevealClipper extends CustomClipper<Path> {
  final double revealFactor;
  final Axis direction;
  RevealClipper({required this.revealFactor, this.direction = Axis.horizontal});

  @override
  Path getClip(Size size) {
    final path = Path();
    if (direction == Axis.horizontal) {
      final revealWidth = size.width * revealFactor;
      path.addRect(Rect.fromLTWH(0, 0, revealWidth, size.height));
    } else {
      final revealHeight = size.height * revealFactor;
      path.addRect(Rect.fromLTWH(0, 0, size.width, revealHeight));
    }
    return path;
  }

  @override
  bool shouldReclip(RevealClipper oldClipper) => revealFactor != oldClipper.revealFactor || direction != oldClipper.direction;
}

class RevealClipDemo extends StatefulWidget {
  const RevealClipDemo({super.key});
  @override
  State<RevealClipDemo> createState() => _RevealClipDemoState();
}

class _RevealClipDemoState extends State<RevealClipDemo> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(duration: const Duration(seconds: 2), vsync: this);
    _animation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
  }

  @override
  void dispose() { _controller.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('揭开动画裁剪')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            AnimatedBuilder(
              animation: _animation,
              builder: (context, child) {
                return ClipPath(
                  clipper: RevealClipper(revealFactor: _animation.value),
                  child: Container(
                    width: 300, height: 200,
                    decoration: const BoxDecoration(gradient: LinearGradient(colors: [Colors.blue, Colors.purple], begin: Alignment.centerLeft, end: Alignment.centerRight)),
                    child: const Center(child: Text('揭开效果', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold))),
                  ),
                );
              },
            ),
            const SizedBox(height: 40),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(onPressed: () => _controller.forward(), child: const Text('播放')),
                const SizedBox(width: 16),
                ElevatedButton(onPressed: () => _controller.reverse(), child: const Text('反向')),
                const SizedBox(width: 16),
                ElevatedButton(onPressed: () => _controller.reset(), child: const Text('重置')),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class CircleRevealClipper extends CustomClipper<Path> {
  final double radiusFactor;
  final Offset center;
  CircleRevealClipper({required this.radiusFactor, required this.center});

  @override
  Path getClip(Size size) {
    final maxRadius = sqrt(pow(size.width, 2) + pow(size.height, 2));
    final radius = maxRadius * radiusFactor;
    return Path()..addOval(Rect.fromCircle(center: center, radius: radius));
  }

  @override
  bool shouldReclip(CircleRevealClipper oldClipper) => radiusFactor != oldClipper.radiusFactor || center != oldClipper.center;
}

class CircleRevealDemo extends StatefulWidget {
  const CircleRevealDemo({super.key});
  @override
  State<CircleRevealDemo> createState() => _CircleRevealDemoState();
}

class _CircleRevealDemoState extends State<CircleRevealDemo> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  Offset _center = Offset.zero;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(duration: const Duration(milliseconds: 800), vsync: this);
    _animation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
  }

  @override
  void dispose() { _controller.dispose(); super.dispose(); }

  void _startAnimation(Offset position) {
    setState(() => _center = position);
    _controller.forward(from: 0);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('圆形扩散裁剪')),
      body: GestureDetector(
        onTapDown: (details) => _startAnimation(details.localPosition),
        child: AnimatedBuilder(
          animation: _animation,
          builder: (context, child) {
            return ClipPath(
              clipper: CircleRevealClipper(radiusFactor: _animation.value, center: _center),
              child: Container(
                width: double.infinity, height: double.infinity,
                decoration: const BoxDecoration(gradient: LinearGradient(colors: [Colors.teal, Colors.cyan], begin: Alignment.topLeft, end: Alignment.bottomRight)),
                child: Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      const Icon(Icons.touch_app, color: Colors.white, size: 48),
                      const SizedBox(height: 16),
                      Text('点击任意位置', style: TextStyle(color: Colors.white.withOpacity(0.9), fontSize: 20, fontWeight: FontWeight.bold)),
                    ],
                  ),
                ),
              ),
            );
          },
        ),
      ),
    );
  }
}

七、最佳实践与注意事项

⚡ 7.1 性能优化建议

ClipPath 是一个强大的裁剪工具,但在使用时需注意以下几点:

1. 避免频繁重建

当裁剪器参数变化时,会触发 shouldReclip 判断是否重新裁剪。确保正确实现此方法,避免不必要的重绘。

2. 简化路径计算

getClip 方法中避免复杂的计算,可以将计算结果缓存起来复用。

3. 合理使用 clipBehavior

根据实际需求选择合适的裁剪行为:

  • Clip.none:不裁剪(最快)
  • Clip.hardEdge:硬边裁剪
  • Clip.antiAlias:抗锯齿裁剪
  • Clip.antiAliasWithSaveLayer:带保存层的抗锯齿裁剪(最慢但效果最好)

🔧 7.2 常见问题与解决方案

问题1:裁剪后内容显示不完整

解决方案:

  • 检查路径是否正确闭合
  • 确保路径坐标在组件尺寸范围内
  • 使用 path.addRect 添加背景区域

问题2:动画裁剪卡顿

解决方案:

  • 使用 AnimatedBuilder 而非 setState
  • 简化路径计算逻辑
  • 考虑使用 RepaintBoundary 隔离重绘区域

问题3:裁剪边缘有锯齿

解决方案:

  • 设置 clipBehavior: Clip.antiAlias
  • 使用更高分辨率的设备
  • 考虑使用 Clip.antiAliasWithSaveLayer

八、总结

本文详细介绍了 Flutter 中 ClipPath 自定义裁剪系统的使用方法,从基础的三角形、多边形裁剪到高级的波浪、优惠券、动画裁剪效果,涵盖了以下核心内容:

  1. ClipPath 核心概念:理解路径裁剪的工作原理和 CustomClipper 的使用
  2. 基础裁剪形状:三角形、多边形、星形等基础形状的实现
  3. 波浪形裁剪:正弦波浪、贝塞尔波浪等曲线裁剪效果
  4. 复杂裁剪效果:优惠券缺口、对角线切割等实用形状
  5. 动画裁剪效果:揭开动画、圆形扩散等动态裁剪效果
  6. 性能优化:合理使用 ClipPath,避免性能问题

ClipPath 是 Flutter 中实现独特视觉形状的核心工具,掌握其使用技巧能够帮助开发者创建更加精美、个性化的用户界面。在实际开发中,需要根据具体场景选择合适的裁剪方式,并注意性能优化。


参考资料

相关推荐
2501_921930832 小时前
进阶实战 Flutter for OpenHarmony:ShaderMask 着色器系统 - 高级视觉效果实现
flutter
2501_921930835 小时前
进阶实战 Flutter for OpenHarmony:复合动画与粒子系统 - 高级视觉效果实现
flutter
2501_921930835 小时前
进阶实战 Flutter for OpenHarmony:Transform 变换矩阵系统 - 高级视觉效果实现
flutter
2501_921930836 小时前
进阶实战 Flutter for OpenHarmony:自定义仪表盘系统 - 高级数据可视化实现
flutter·信息可视化
2601_949593656 小时前
进阶实战 Flutter for OpenHarmony:InheritedWidget 组件实战 - 跨组件数据
flutter
阿林来了6 小时前
Flutter三方库适配OpenHarmony【flutter_speech】— 持续语音识别与长录音
flutter·语音识别·harmonyos
lili-felicity6 小时前
进阶实战 Flutter for OpenHarmony:mobile_device_identifier 第三方库实战 - 设备标识
flutter
松叶似针7 小时前
Flutter三方库适配OpenHarmony【secure_application】— 与 HarmonyOS 安全能力的深度集成
安全·flutter·harmonyos