SwiftUI - 3D卡片

420 阅读2分钟

3D卡片

Simulator Screen Recording - iPhone 14 Pro - 2022-.gif

想要实现这个效果,需要处理以下几点:

  1. 手势处理

  2. 手势转换为3D角度

  3. 角度影响视图

1. 手势处理

这种3D卡片本身没有什么技术难度,也就是多层的图像堆积,想要实现这种跟随手势的3D效果,需要获取手势移动的距离,同时需要注意在手势终止之后需要将视图恢复到初始状态。

  1. 手势监听 手势监听只需要将手势的移动距离保存起来,在SwiftUI中使用一个@State变量保存即可

  2. 手势终止后将视图恢复到初始状态 手势终止后将移动距离变量恢复为0即可

具体代码如下:

.gesture(
    DragGesture()
        .onChanged { value in
            offset = value.translation
        }
        .onEnded { _ in
            withAnimation(.interactiveSpring(response: 0.6, dampingFraction: 0.32, blendDuration: 0.32)) {
                offset = .zero
            }
        }
)

2. 将手势转换为3D角度

当手指在页面上向下拖动时,卡片对应的向下方旋转(对应旋转轴是x);当手指在视图上左右拖动时,卡片对应左右旋转(对应旋转轴是y);

至于拖动的距离与旋转角度的关系就随你定义。这里取 10/屏幕长宽。

func offset2Angle(_ isVertical: Bool = false) -> Angle {
  let progress = (isVertical ? -offset.height : offset.width) / (isVertical ? screenSize.height : screenSize.width)
  return Angle(degress: progress * 10)  
}

这里因为需要计算两个维度的角度,然后通过组合来实现立体的旋转,所以增加一个条件以区别两个角度。

这里还有一个辅助函数用于获取屏幕尺寸,就不展开说了。

var screenSize: CGSize = {
    guard let window = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return .zero }
    return window.screen.bounds.size
}()

3. 角度影响视图

最后就是最简单的一个步骤:给视图添加3D旋转

.rotation3DEffect(offset2Angle(true), axis: (x: 1, y: 0, z: 0)) // 旋转轴为x
.rotation3DEffect(offset2Angle(), axis: (x: 0, y: 1, z: 0)) // 旋转轴为y

为了使得3D效果更明显,还可以在前景视图上添加offset位移,让卡片旋转时带动前景移动

.offset(x: offset2Angle().degress * 5, y: -offset2Angle(true).degress * 5)

总结

这种3D效果看起来很厉害,实际上运用到的知识却很简单,关键在于空间理解能力。

完整代码

struct ContentView: View {
    @State var offset: CGSize = .zero
    var body: some View {
        GeometryReader { proxy in
            let size = proxy.size
            let imageSize = size.width * 0.7
            
            VStack {
                Image("img")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: imageSize)
                    .zIndex(1)
                    .offset(x: offset2Angle().degrees * 5, y: -offset2Angle(true).degrees * 5)
            }
            .padding(.top, 65)
            .padding(.horizontal, 15)
            .padding(.bottom, 30)
            .frame(width: imageSize)
            .background {
                ZStack {
                    Rectangle()
                        .fill(.blue)
                }
                .clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous))
            }
            .rotation3DEffect(offset2Angle(true), axis: (x: 1, y: 0, z: 0))
            .rotation3DEffect(offset2Angle(), axis: (x: 0, y: 1, z: 0))
            .scaleEffect(0.9)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .contentShape(Rectangle())
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                    }
                    .onEnded { _ in
                        withAnimation(.interactiveSpring(response: 0.6, dampingFraction: 0.32, blendDuration: 0.32)) {
                            offset = .zero
                        }
                    }
            )
        }
    }
    
    func offset2Angle(_ isVertical: Bool = false) -> Angle {
        let progress = (isVertical ? -offset.height : offset.width) / (isVertical ? screenSize.height : screenSize.width)
        return .init(degrees: progress * 10)
    }
    
    var screenSize: CGSize = {
        guard let window = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return .zero }
        return window.screen.bounds.size
    }()
}