斜切(Skew/Shear)是平面几何中一种非常经典的变换,它能让矩形变成平行四边形,让元素在动画中呈现出剪切滑动的视觉效果。在CSS里一个transform: skewX()就能搞定的事情,到了SwiftUI却让不少人犯了难——官方文档里翻不到任何与skew相关的修饰符。实际上,SwiftUI提供了多条实现路径:既可以用transformEffect直接应用仿射矩阵,也可以用projectionEffect做透视扭曲,还能通过实现自定义Transition把斜切效果融入视图的插入与移除动画中。本文会把这套东西彻底讲透。

斜切变换的数学原理与CGAffineTransform
在动手写代码之前,先弄清楚斜切到底改变了什么。仿射变换矩阵有六个参数:a、b、c、d、tx、ty,分别对应Core Graphics里CGAffineTransform的六个字段。在Swift的构造方法中,它们又叫m11、m12、m21、m22与平移量。其中m11和m22控制缩放,m12控制Y方向随X的倾斜,m21控制X方向随Y的倾斜。
所谓X轴斜切(skewX),就是让每个点的横坐标根据纵坐标发生偏移,同时纵坐标保持不变。对应的矩阵是m11 = 1、m22 = 1、m21 = tan(angle)、m12 = 0。角度越大,倾斜越明显,当角度接近90度时会退化成一条线,所以实际使用中通常控制在30度以内。理解这一点非常关键,因为SwiftUI的transformEffect接收的正是这个矩阵,你必须自己算好参数,而不能像CSS那样传个角度就完事。
还有一个容易忽略的细节:斜切是围绕视图左上角原点进行的,这会导致视图整体发生位移。如果你希望视图围绕中心斜切,需要先用concatenating把平移拼接进去。下面这段代码封装了一个围绕中心点的斜切修饰符,可以直接复用:
extension View {
/// 围绕视图中心进行X轴斜切
func skewX(angle: Angle, anchor: CGPoint = .zero) -> some View {
let t = CGFloat(tan(angle.radians))
// 先把锚点移到原点,斜切后再移回去
let translate = CGAffineTransform(translationX: anchor.x, y: anchor.y)
let skew = CGAffineTransform(a: 1, b: 0, c: t, d: 1, tx: 0, ty: 0)
let reverse = CGAffineTransform(translationX: -anchor.x, y: -anchor.y)
return self.transformEffect(
translate.concatenating(skew).concatenating(reverse)
)
}
}
// 使用示例:让文本倾斜15度
Text("SKEW EFFECT")
.font(.system(size: 32, weight: .black))
.skewX(angle: .degrees(15), anchor: CGPoint(x: 100, y: 20))
这里传入的anchor是视图中心点坐标,可以通过GeometryReader拿到。值得强调的是,transformEffect只影响绘制层,不会改变视图的布局frame,也就是说点击区域、父容器的排版都保持原样。这个特性在做纯视觉倾斜时是优点,但如果你希望按钮的命中区域跟着变形,就得换用rotationEffect同级的方案或者自定义Shape了。
用GeometryReader打造可交互的Shear动画
静态斜切看一次就腻了,真正有价值的场景是跟手势或动画联动。思路是用GeometryReader获取视图尺寸,把中心点作为锚点传给斜切修饰符,再用withAnimation驱动角度变化。由于transformEffect是可动画的修饰符,角度变化时会自动产生平滑插值,不需要手动写定时器。
下面是一个跟拖动手势联动的例子,手指左右滑动时卡片会跟随倾斜,松手后回弹到原位,效果类似一些App的卡片切换交互:
struct ShearCard: View {
@State private var dragX: CGFloat = 0
// 根据拖拽距离换算倾斜角度,限制在正负20度
private var skewAngle: Angle {
.degrees(Double(dragX / 10).clamped(to: -20...20))
}
var body: some View {
GeometryReader { geo in
RoundedRectangle(cornerRadius: 16)
.fill(
LinearGradient(colors: [.indigo, .purple],
startPoint: .topLeading,
endPoint: .bottomTrailing)
)
.frame(width: 280, height: 180)
.overlay(
Text("拖动我")
.font(.title.bold())
.foregroundStyle(.white)
)
.skewX(angle: skewAngle,
anchor: CGPoint(x: geo.size.width / 2,
y: geo.size.height / 2))
.gesture(
DragGesture()
.onChanged { value in
dragX = value.translation.width
}
.onEnded { _ in
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
dragX = 0
}
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
extension Comparable {
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}
这个例子里有两个值得注意的设计。第一,角度换算用了除以10的比例系数,这样手指移动100点才会产生10度倾斜,手感比较自然,实际项目中可以根据视图宽度做归一化。第二,松手回弹用的是spring动画而不是普通的easeOut,因为斜切回正配合弹簧的轻微过冲会让画面更有弹性,如果改用线性动画会显得非常生硬。
另外提醒一点,如果你在斜切视图上叠加了阴影,注意shadow修饰符的书写顺序。transformEffect位于shadow之后时,阴影会跟随变形后的形状;顺序反了则阴影保持矩形轮廓,视觉上会出现穿帮。
自定义Transition实现斜切过渡效果
SwiftUI的Transition协议是插入移除动画的灵魂。默认的.opacity、.slide效果有限,而自定义Transition配合斜切,可以做出非常有冲击力的切换动画,比如新视图从侧面以平行四边形的形态斜着切入屏幕。实现Transition只需要写一个遵循Transition协议的结构体,在body方法里根据phase判断当前是活动状态还是消失状态,返回不同的修饰后视图。
下面的实现里,活跃状态视图正常显示,移除时视图向左平移并斜切30度,同时透明度衰减到零:
struct SkewOutTransition: Transition {
var angle: Angle = .degrees(30)
func body(content: Content, phase: TransitionPhase) -> some View {
content
.skewX(angle: phase.isIdentity ? .zero : angle,
anchor: CGPoint(x: 200, y: 100))
.offset(x: phase.isIdentity ? 0 : -80)
.opacity(phase.isIdentity ? 1 : 0)
}
}
struct ContentView: View {
@State private var showDetail = false
var body: some View {
ZStack {
if showDetail {
DetailView()
.transition(
.asymmetric(
insertion: .scale(scale: 0.9).combined(with: .opacity),
removal: SkewOutTransition()
)
)
} else {
MainView()
}
}
.animation(.easeInOut(duration: 0.45), value: showDetail)
}
}
这里用了asymmetric让插入和移除走不同的动画曲线,是实际项目里很常见的组合技巧。phase.isIdentity是SwiftUI提供的布尔值,过渡进行中为false,动画归位后为true。你也可以通过phase.value拿到0到1之间的进度值,用它做更精细的插值控制,比如让倾斜角度从60度递减到30度,制造减速入场的观感。
需要说明的是,Transition里锚点坐标目前拿不到视图实际尺寸,上面例子写死了200和100。更优雅的做法是把Transition和GeometryReader封装在ViewModifier层面,或者在Transition内部用.modifier配合包裹一层容器来获取尺寸。如果你的部署目标是iOS 17及以上,还可以直接利用phase.value配合visualEffect修饰符,在几何上下文中拿到精确的proxy尺寸,这是目前最干净的方案。
进阶玩法与性能注意事项
掌握了基础斜切之后,还可以往两个方向扩展。其一是透视扭曲:把transformEffect换成projectionEffect,传入ProjectionTransform,可以做出三维透视下的梯形变形,配合旋转动画能实现翻转卡片般的立体感。其二是斜切Shape:用Path手绘平行四边形路径,让填充内容裁剪在斜切轮廓内,这种方式会真实改变命中测试区域,适合做斜切按钮和倾斜标签。
struct Parallelogram: Shape {
var skew: CGFloat = 20
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX + skew, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - skew, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.closeSubpath()
return path
}
}
// 用法:命中区域会真实变成平行四边形
Parallelogram(skew: 24)
.fill(Color.orange)
.frame(width: 160, height: 44)
.overlay(Text("按钮").foregroundStyle(.white))
性能方面,transformEffect走的是GPU渲染管线,对简单视图的开销几乎可以忽略,但要注意避免在长列表中给大量单元格同时做连续斜切动画,特别是在低端机型上,每帧的矩阵重算和离屏渲染叠加起来可能掉帧。如果确实需要在列表中大面积使用,建议把斜切部分抽成独立视图,配合drawingGroup将其光栅化,能把渲染压力降下来一个量级。
最后总结一下选择思路:只需要视觉倾斜就用transformEffect方案,成本最低;需要交互区域跟着变形就用自定义Shape;需要立体透视感就上projectionEffect;而视图切换场景则把斜切封装成Transition。这四种手段组合起来,基本能覆盖设计稿里所有与斜切、扭曲相关的动效需求。
SwiftUISkew TransitionShear动画修改时间:2026-09-11 22:38:54