21
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

【SwiftUI】カスタムビジュアルエフェクト with SwiftUI - WWDC2024

Last updated at Posted at 2024-07-02

はじめに

WWDC24で紹介されていた「Create custom visual effects with SwiftUI」が感動したので紹介します。実際のコードも各UI毎に作成したので是非試してみてください!23分の動画を2分で読めます!

前提条件

  • Xcode 16

  • iOS 18

  • Assetsに適当な画像を追加
    スクリーンショット 2024-06-25 17.35.35.png

カスタムビジュアルエフェクト

Scroll effects

写真のコレクションを横方向のスクロールビューで表示し、スクロールトランジションを使用してカスタムエフェクトを作成します。

エフェクトなし エフェクト1 エフェクト2
0_0.gif 1.gif 2.gif
スクロールエフェクトなし
struct ContentView: View {

    let photos = [
        Photo("0"),
        Photo("1"),
        Photo("2"),
        Photo("3"),
        Photo("4"),
        Photo("5"),
        Photo("6")
    ]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 16) {
                ForEach(photos) { photo in
                    VStack {
                        ItemPhoto(photo)
                            .containerRelativeFrame(.horizontal)
                            .clipShape(RoundedRectangle(cornerRadius: 36))
                    }
                }
            }
        }
        .contentMargins(32)
        .scrollTargetBehavior(.paging)
    }
}

#Preview {
    ContentView()
}

struct Photo: Identifiable {
    var title: String
    var id: Int = .random(in: 0 ... 100)

    init(_ title: String) {
        self.title = title
    }
}

struct ItemPhoto: View {
    var photo: Photo

    init(_ photo: Photo) {
        self.photo = photo
    }

    var body: some View {
        Image(photo.title)
            .resizable()
            .scaledToFill()
            .frame(height: 400)
            .containerRelativeFrame(.horizontal)
            .clipShape(RoundedRectangle(cornerRadius: 36))
    }
}

スクロールエフェクト1
struct ContentView: View {

    let photos = [
        Photo("0"),
        Photo("1"),
        Photo("2"),
        Photo("3"),
        Photo("4"),
        Photo("5"),
        Photo("6")
    ]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 16) {
                ForEach(photos) { photo in
                    VStack {
                        ZStack {
                            ItemPhoto(photo)
                                .scrollTransition(axis: .horizontal) { content, phase in
                                    content
                                        .rotationEffect(.degrees(phase.value * 3.5))
                                        .offset(y: phase.isIdentity ? 0 : 16)
                                }
                        }
                        .containerRelativeFrame(.horizontal)
                        .clipShape(RoundedRectangle(cornerRadius: 36))
                    }
                }
            }
        }
        .contentMargins(32)
        .scrollTargetBehavior(.paging)
    }
}

#Preview {
    ContentView()
}

struct Photo: Identifiable {
    var title: String
    var id: Int = .random(in: 0 ... 100)

    init(_ title: String) {
        self.title = title
    }
}

struct ItemPhoto: View {
    var photo: Photo

    init(_ photo: Photo) {
        self.photo = photo
    }

    var body: some View {
        Image(photo.title)
            .resizable()
            .scaledToFill()
            .frame(height: 400)
            .containerRelativeFrame(.horizontal)
            .clipShape(RoundedRectangle(cornerRadius: 36))
    }
}
スクロールエフェクト2
struct ContentView: View {

    let photos = [
        Photo("0"),
        Photo("1"),
        Photo("2"),
        Photo("3"),
        Photo("4"),
        Photo("5"),
        Photo("6")
    ]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 16) {
                ForEach(photos) { photo in
                    VStack {
                        ZStack {
                            ItemPhoto(photo)
                                .scrollTransition(axis: .horizontal) { content, phase in
                                    content
                                        .offset(x: phase.isIdentity ? 0 : phase.value * -200)
                                }
                        }
                        .containerRelativeFrame(.horizontal)
                        .clipShape(RoundedRectangle(cornerRadius: 36))
                    }
                }
            }
        }
        .contentMargins(32)
        .scrollTargetBehavior(.paging)
    }
}

#Preview {
    ContentView()
}

struct Photo: Identifiable {
    var title: String
    var id: Int = .random(in: 0 ... 100)

    init(_ title: String) {
        self.title = title
    }
}

struct ItemPhoto: View {
    var photo: Photo

    init(_ photo: Photo) {
        self.photo = photo
    }

    var body: some View {
        Image(photo.title)
            .resizable()
            .scaledToFill()
            .frame(height: 400)
            .containerRelativeFrame(.horizontal)
            .clipShape(RoundedRectangle(cornerRadius: 36))
    }
}

struct ItemLabel: View {
    var photo: Photo

    init(_ photo: Photo) {
        self.photo = photo
    }

    var body: some View {
        Text(photo.title)
            .font(.title)
    }
}
色合い変更なし 色合い変更1 色合い変更2
1_0.gif 1_1.gif 1_2.gif
色合い変更なし
struct ContentView: View {

    var body: some View {
        ScrollView(.vertical) {
            VStack {
                ForEach(0 ..< 20) { _ in
                    RoundedRectangle(cornerRadius: 24)
                        .fill(.purple)
                        .frame(height: 100)
                }
            }
            .padding()
        }
    }
}

#Preview {
    ContentView()
}
色合い変更1
struct ContentView: View {

    var body: some View {
        ScrollView(.vertical) {
            VStack {
                ForEach(0 ..< 20) { _ in
                    RoundedRectangle(cornerRadius: 24)
                        .fill(.blue)
                        .frame(height: 100)
                        .visualEffect { content, proxy in
                            content
                                .hueRotation(.degrees(proxy.frame(in: .global).origin.y / 10))
                        }
                }
            }
            .padding()
        }
    }
}

#Preview {
    ContentView()
}
色合い変更2
struct ContentView: View {

    var body: some View {
        ScrollView(.vertical) {
            VStack {
                ForEach(0 ..< 20) { _ in
                    RoundedRectangle(cornerRadius: 24)
                        .fill(.blue)
                        .frame(height: 100)
                        .visualEffect { content, proxy in
                            let frame = proxy.frame(in: .scrollView(axis: .vertical))
                            let parentBounds = proxy
                                .bounds(of: .scrollView(axis: .vertical)) ??
                                .infinite

                            let distance = min(0, frame.minY)

                            return content
                                .hueRotation(.degrees(frame.origin.y / 10))
                                .scaleEffect(1 + distance / 700)
                                .offset(y: -distance / 1.25)
                                .brightness(-distance / 400)
                                .blur(radius: -distance / 50)
                        }
                }
            }
            .padding()
        }
    }
}

#Preview {
    ContentView()
}

Color treatments

メッシュグラデーションです。カスタマイズ性のあがったリニアグラディエントと言ったところでしょうか。ポイントごとの色を補間してグラデーションを作ります。

メッシュグラディエント1 メッシュグラディエント2 メッシュグラディエント3
2_1.png 2_2.gif 2-3.gif
メッシュグラディエント1
struct ContentView: View {

    var body: some View {
        MeshGradient(
            width: 3,
            height: 3,
            points: [
                [0.0, 0.0], [0.5, 0.0], [1.0, 0.0],
                [0.0, 0.5], [0.8, 0.2], [1.0, 0.5],
                [0.0, 1.0], [0.5, 1.0], [1.0, 1.0]
            ], colors: [
                .black, .black, .black,
                .blue, .blue, .blue,
                .green, .green, .green
            ])
            .edgesIgnoringSafeArea(.all)
    }
}

#Preview {
    ContentView()
}

メッシュグラディエント2
struct ContentView: View {

    @State var colors: [Color] = [
        .black, .black, .black,
        .blue, .blue, .blue,
        .green, .green, .green
    ]

    var body: some View {
        MeshGradient(
            width: 3,
            height: 3,
            points: [
                [0.0, 0.0], [0.4, 0.0], [1.0, 0.0],
                [0.0, 0.5], [0.7, 0.4], [1.0, 0.5],
                [0.0, 1.0], [0.3, 1.0], [1.0, 1.0]
            ], colors: colors)
            .edgesIgnoringSafeArea(.all)
            .onAppear {
                startTimer()
            }
    }

    func startTimer() {
        Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { timer in
            withAnimation(.easeInOut(duration: 1.0)) {
                colors = [
                    Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
                    Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
                    Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
                    Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
                    Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
                    Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
                    Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
                    Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
                    Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1))
                ]
            }
        }
    }
}

#Preview {
    ContentView()
}

メッシュグラディエント2

struct ContentView: View {
    @State private var isAnimating = false
    @State private var colors: [Color] = [
        .red, .purple, .indigo,
        .orange, .white, .blue,
        .yellow, .green, .mint
    ]

    var body: some View {
        MeshGradient(width: 3, height: 3, points: [
            [0.0, 0.0], [0.5, 0], [1.0, 0.0],
            [0.0, 0.5], [0.5, 0.5], [1.0, 0.5],
            [0.0, 1.0], [0.5, 1.0], [1.0, 1.0]
        ], colors: colors,
            smoothsColors: true,
            colorSpace: .perceptual
        )
        .edgesIgnoringSafeArea(.all)
        .onAppear {
            startColorRotation()
            withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
                isAnimating.toggle()
            }
        }
    }

    func startColorRotation() {
        Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
            withAnimation(.easeInOut(duration: 1.0)) {
                rotateColors()
            }
        }
    }

    func rotateColors() {
        // 白以外の色を回転させる
        colors = [
            colors[8], colors[0], colors[1],
            colors[7], colors[4], colors[2],
            colors[6], colors[5], colors[3]
        ]
    }
}


#Preview {
    ContentView()
}

View transitions

ビューの表示と非表示の際のトランジションをカスタマイズします。

ビュートランジション1 ビュートランジション2
4_0.gif 4_1.gif
ビュートランジション1
struct ContentView: View {

    @State var isVisible: Bool = true

    var body: some View {
        VStack {
            GroupBox {
                Toggle("Visible", isOn: $isVisible.animation())
            }

            Spacer()

            if isVisible {
                Avatar()
                    .transition(.scale.combined(with: .opacity))
            }
            Spacer()
        }
        .padding()
    }
}


struct Avatar: View {
var body: some View {
    Circle()
        .fill(.red).opacity(0.2)
        .overlay {
            Image(systemName: "sun.dust.fill")
                .resizable()
                .scaledToFit()
                .scaleEffect(0.5)
                .foregroundStyle(.red)
        }
        .frame(width: 80, height: 80)
        .compositingGroup()
}
}

#Preview {
    ContentView()
}
ビュートランジション2
struct ContentView: View {

    @State var isVisible: Bool = true

    var body: some View {
        VStack {
            GroupBox {
                Toggle("Visible", isOn: $isVisible.animation())
            }

            Spacer()

            if isVisible {
                Avatar()
                    .transition(Twirl())
            }
            
            Spacer()
        }
        .padding()
    }
}


struct Avatar: View {
var body: some View {
    Circle()
        .fill(.red).opacity(0.2)
        .overlay {
            Image(systemName: "sun.dust.fill")
                .resizable()
                .scaledToFit()
                .scaleEffect(0.5)
                .foregroundStyle(.red)
        }
        .frame(width: 200, height: 200)
        .compositingGroup()
}
}


struct Twirl: Transition {
    func body(content: Content, phase: TransitionPhase) -> some View {
        content
            .scaleEffect(phase.isIdentity ? 1 : 0.5)
            .opacity(phase.isIdentity ? 1 : 0)
            .blur(radius: phase.isIdentity ? 0 : 10)
            .rotationEffect(
                .degrees(
                    phase == .willAppear ? 360 :
                        phase == .didDisappear ? -360 : .zero
                )
            )
            .brightness(phase == .willAppear ? 1 : 0)
    }
}

#Preview {
    ContentView()
}

Text transitions

iOS 18で導入されたTextRenderer APIを使用して、テキストの行ごとのアニメーションを作成します。

テキストトランジション1 テキストトランジション2
5_0.gif 5_1.gif
テキストトランジション1
struct ContentView: View {

    @State var isVisible: Bool = true

    var body: some View {

        VStack {
            GroupBox {
                Toggle("Visible", isOn: $isVisible.animation())
            }

            Spacer()

            if isVisible {
                let visualEffects = Text("ビジュアルエフェクト")
                    .customAttribute(EmphasisAttribute())
                    .foregroundStyle(.pink)
                    .bold()

                Text("Build \(visualEffects) with SwiftUI 🧑‍💻")
                    .font(.system(.title, design: .rounded, weight: .semibold))
                    .frame(width: 350)
                    .transition(TextTransition())
            }

            Spacer()
        }
        .multilineTextAlignment(.center)
        .padding()
    }
}

struct EmphasisAttribute: TextAttribute {}

struct AppearanceEffectRenderer: TextRenderer, Animatable {

    var elapsedTime: TimeInterval
    var elementDuration: TimeInterval
    var totalDuration: TimeInterval

    var spring: Spring {
        .snappy(duration: elementDuration - 0.05, extraBounce: 0.4)
    }

    var animatableData: Double {
        get { elapsedTime }
        set { elapsedTime = newValue }
    }

    init(elapsedTime: TimeInterval, elementDuration: Double = 0.4, totalDuration: TimeInterval) {
        self.elapsedTime = min(elapsedTime, totalDuration)
        self.elementDuration = min(elementDuration, totalDuration)
        self.totalDuration = totalDuration
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        for run in layout.flattenedRuns {
            if run[EmphasisAttribute.self] != nil {
                let delay = elementDelay(count: run.count)

                for (index, slice) in run.enumerated() {
                    let timeOffset = TimeInterval(index) * delay


                    let elementTime = max(0, min(elapsedTime - timeOffset, elementDuration))

                    var copy = context
                    draw(slice, at: elementTime, in: &copy)
                }
            } else {
                var copy = context

                copy.opacity = UnitCurve.easeIn.value(at: elapsedTime / 0.2)
                copy.draw(run)
            }
        }
    }

    func draw(_ slice: Text.Layout.RunSlice, at time: TimeInterval, in context: inout GraphicsContext) {

        let progress = time / elementDuration

        let opacity = UnitCurve.easeIn.value(at: 1.4 * progress)

        let blurRadius =
            slice.typographicBounds.rect.height / 16 *
            UnitCurve.easeIn.value(at: 1 - progress)
        let translationY = spring.value(
            fromValue: -slice.typographicBounds.descent,
            toValue: 0,
            initialVelocity: 0,
            time: time)

        context.translateBy(x: 0, y: translationY)
        context.addFilter(.blur(radius: blurRadius))
        context.opacity = opacity
        context.draw(slice, options: .disablesSubpixelQuantization)
    }

    func elementDelay(count: Int) -> TimeInterval {
        let count = TimeInterval(count)
        let remainingTime = totalDuration - count * elementDuration

        return max(remainingTime / (count + 1), (totalDuration - elementDuration) / count)
    }
}

extension Text.Layout {
    var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> {
        self.flatMap { line in
            line
        }
    }

    var flattenedRunSlices: some RandomAccessCollection<Text.Layout.RunSlice> {
        flattenedRuns.flatMap(\.self)
    }
}

struct TextTransition: Transition {
    static var properties: TransitionProperties {
        TransitionProperties(hasMotion: true)
    }

    func body(content: Content, phase: TransitionPhase) -> some View {
        let duration = 0.9
        let elapsedTime = phase.isIdentity ? duration : 0
        let renderer = AppearanceEffectRenderer(
            elapsedTime: elapsedTime,
            totalDuration: duration
        )

        content.transaction { transaction in
            if !transaction.disablesAnimations {
                transaction.animation = .linear(duration: duration)
            }
        } body: { view in
            view.textRenderer(renderer)
        }
    }
}


#Preview {
    ContentView()
}

テキストトランジション2
struct ContentView: View {

    @State var isVisible: Bool = true
    @State var time: TimeInterval = 0.3

    var body: some View {
        VStack {
            GroupBox {
                HStack {
                    Text("Progress")
                    Slider(value: $time, in: 0 ... 0.8)
                }
            }

            Spacer()

            let visualEffects = Text("Visual Effects")
                .customAttribute(EmphasisAttribute())
                .foregroundStyle(.pink)
                .bold()

            Text("Build \(visualEffects) with SwiftUI 🧑‍💻")
                .font(.system(.title, design: .rounded, weight: .semibold))
                .frame(width: 250)
                .textRenderer(AppearanceEffectRenderer(elapsedTime: time, totalDuration: 0.8))

            Spacer()
        }
        .multilineTextAlignment(.center)
        .padding()
    }
}

struct EmphasisAttribute: TextAttribute {}

struct AppearanceEffectRenderer: TextRenderer, Animatable {

    var elapsedTime: TimeInterval
    var elementDuration: TimeInterval
    var totalDuration: TimeInterval

    var spring: Spring {
        .snappy(duration: elementDuration - 0.05, extraBounce: 0.4)
    }

    var animatableData: Double {
        get { elapsedTime }
        set { elapsedTime = newValue }
    }

    init(elapsedTime: TimeInterval, elementDuration: Double = 0.4, totalDuration: TimeInterval) {
        self.elapsedTime = min(elapsedTime, totalDuration)
        self.elementDuration = min(elementDuration, totalDuration)
        self.totalDuration = totalDuration
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        for run in layout.flattenedRuns {
            if run[EmphasisAttribute.self] != nil {
                let delay = elementDelay(count: run.count)

                for (index, slice) in run.enumerated() {
                    let timeOffset = TimeInterval(index) * delay


                    let elementTime = max(0, min(elapsedTime - timeOffset, elementDuration))

                    var copy = context
                    draw(slice, at: elementTime, in: &copy)
                }
            } else {
                var copy = context

                copy.opacity = UnitCurve.easeIn.value(at: elapsedTime / 0.2)
                copy.draw(run)
            }
        }
    }

    func draw(_ slice: Text.Layout.RunSlice, at time: TimeInterval, in context: inout GraphicsContext) {

        let progress = time / elementDuration

        let opacity = UnitCurve.easeIn.value(at: 1.4 * progress)

        let blurRadius =
            slice.typographicBounds.rect.height / 16 *
            UnitCurve.easeIn.value(at: 1 - progress)
        let translationY = spring.value(
            fromValue: -slice.typographicBounds.descent,
            toValue: 0,
            initialVelocity: 0,
            time: time)

        context.translateBy(x: 0, y: translationY)
        context.addFilter(.blur(radius: blurRadius))
        context.opacity = opacity
        context.draw(slice, options: .disablesSubpixelQuantization)
    }

    func elementDelay(count: Int) -> TimeInterval {
        let count = TimeInterval(count)
        let remainingTime = totalDuration - count * elementDuration

        return max(remainingTime / (count + 1), (totalDuration - elementDuration) / count)
    }
}

extension Text.Layout {
    var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> {
        self.flatMap { line in
            line
        }
    }

    var flattenedRunSlices: some RandomAccessCollection<Text.Layout.RunSlice> {
        flattenedRuns.flatMap(\.self)
    }
}

struct TextTransition: Transition {
    static var properties: TransitionProperties {
        TransitionProperties(hasMotion: true)
    }

    func body(content: Content, phase: TransitionPhase) -> some View {
        let duration = 0.9
        let elapsedTime = phase.isIdentity ? duration : 0
        let renderer = AppearanceEffectRenderer(
            elapsedTime: elapsedTime,
            totalDuration: duration
        )

        content.transaction { transaction in
            if !transaction.disablesAnimations {
                transaction.animation = .linear(duration: duration)
            }
        } body: { view in
            view.textRenderer(renderer)
        }
    }
}


#Preview {
    ContentView()
}

Metal shaders🤘

今回はじめてメタル入門💀しました。面白いですね〜。

メタルシェーダー1 メタルシェーダー2
6_0.gif 6_1.gif
メタルシェーダー1
struct ContentView: View {

    @State var counter: Int = 0
    @State var origin: CGPoint = .zero

    var body: some View {

        VStack {
            Spacer()

            Image("hawaii")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .clipShape(RoundedRectangle(cornerRadius: 24))
                .onPressingChanged { point in
                    if let point {
                        origin = point
                        counter += 1
                    }
                }
                .modifier(RippleEffect(at: origin, trigger: counter))

            Spacer()
        }
        .padding()
    }
}

struct PushEffect<T: Equatable>: ViewModifier {
    var trigger: T

    func body(content: Content) -> some View {
        content.keyframeAnimator(
            initialValue: 1.0,
            trigger: trigger
        ) { view, value in
            view.visualEffect { view, _ in
                view.scaleEffect(value)
            }
        } keyframes: { _ in
            SpringKeyframe(0.95, duration: 0.2, spring: .snappy)
            SpringKeyframe(1.0, duration: 0.2, spring: .bouncy)
        }
    }
}

/// A modifer that performs a ripple effect to its content whenever its
/// trigger value changes.
struct RippleEffect<T: Equatable>: ViewModifier {
    var origin: CGPoint

    var trigger: T

    init(at origin: CGPoint, trigger: T) {
        self.origin = origin
        self.trigger = trigger
    }

    func body(content: Content) -> some View {
        let origin = origin
        let duration = duration

        content.keyframeAnimator(
            initialValue: 0,
            trigger: trigger
        ) { view, elapsedTime in
            view.modifier(RippleModifier(
                origin: origin,
                elapsedTime: elapsedTime,
                duration: duration
            ))
        } keyframes: { _ in
            MoveKeyframe(0)
            LinearKeyframe(duration, duration: duration)
        }
    }

    var duration: TimeInterval { 3 }
}

/// A modifier that applies a ripple effect to its content.
struct RippleModifier: ViewModifier {
    var origin: CGPoint

    var elapsedTime: TimeInterval

    var duration: TimeInterval

    var amplitude: Double = 12
    var frequency: Double = 15
    var decay: Double = 8
    var speed: Double = 1200

    func body(content: Content) -> some View {
        let shader = ShaderLibrary.Ripple(
            .float2(origin),
            .float(elapsedTime),

            // Parameters
            .float(amplitude),
            .float(frequency),
            .float(decay),
            .float(speed)
        )

        let maxSampleOffset = maxSampleOffset
        let elapsedTime = elapsedTime
        let duration = duration

        content.visualEffect { view, _ in
            view.layerEffect(
                shader,
                maxSampleOffset: maxSampleOffset,
                isEnabled: 0 < elapsedTime && elapsedTime < duration
            )
        }
    }

    var maxSampleOffset: CGSize {
        CGSize(width: amplitude, height: amplitude)
    }
}

extension View {
    func onPressingChanged(_ action: @escaping (CGPoint?) -> Void) -> some View {
        modifier(SpatialPressingGestureModifier(action: action))
    }
}

struct SpatialPressingGestureModifier: ViewModifier {
    var onPressingChanged: (CGPoint?) -> Void

    @State var currentLocation: CGPoint?

    init(action: @escaping (CGPoint?) -> Void) {
        self.onPressingChanged = action
    }

    func body(content: Content) -> some View {
        let gesture = SpatialPressingGesture(location: $currentLocation)

        content
            .gesture(gesture)
            .onChange(of: currentLocation, initial: false) { _, location in
                onPressingChanged(location)
            }
    }
}

struct SpatialPressingGesture: UIGestureRecognizerRepresentable {
    final class Coordinator: NSObject, UIGestureRecognizerDelegate {
        @objc
        func gestureRecognizer(
            _ gestureRecognizer: UIGestureRecognizer,
            shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
        ) -> Bool {
            true
        }
    }

    @Binding var location: CGPoint?

    func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
        Coordinator()
    }

    func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
        let recognizer = UILongPressGestureRecognizer()
        recognizer.minimumPressDuration = 0
        recognizer.delegate = context.coordinator

        return recognizer
    }

    func handleUIGestureRecognizerAction(
        _ recognizer: UIGestureRecognizerType, context: Context) {
            switch recognizer.state {
                case .began:
                    location = context.converter.localLocation
                case .ended, .cancelled, .failed:
                    location = nil
                default:
                    break
            }
        }
    }



#Preview {
    ContentView()
}

メタルシェーダー2
struct ContentView: View {

    @State var origin: CGPoint = .zero
    @State var time: TimeInterval = 0.3
    @State var amplitude: TimeInterval = 12
    @State var frequency: TimeInterval = 15
    @State var decay: TimeInterval = 8

    var body: some View {

        VStack {
            GroupBox {
                Grid {
                    GridRow {
                        VStack(spacing: 4) {
                            Text("Time")
                            Slider(value: $time, in: 0 ... 2)
                        }
                        VStack(spacing: 4) {
                            Text("Amplitude")
                            Slider(value: $amplitude, in: 0 ... 100)
                        }
                    }
                    GridRow {
                        VStack(spacing: 4) {
                            Text("Frequency")
                            Slider(value: $frequency, in: 0 ... 30)
                        }
                        VStack(spacing: 4) {
                            Text("Decay")
                            Slider(value: $decay, in: 0 ... 20)
                        }
                    }
                }
                .font(.subheadline)
            }

            Spacer()

            Image("pipi")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .clipShape(RoundedRectangle(cornerRadius: 24))
                .modifier(RippleModifier(origin: origin, elapsedTime: time, duration: 2, amplitude: amplitude, frequency: frequency, decay: decay))
                .onTapGesture {
                    origin = $0
                }

            Spacer()
        }
        .padding(.horizontal)
    }
}

struct PushEffect<T: Equatable>: ViewModifier {
    var trigger: T

    func body(content: Content) -> some View {
        content.keyframeAnimator(
            initialValue: 1.0,
            trigger: trigger
        ) { view, value in
            view.visualEffect { view, _ in
                view.scaleEffect(value)
            }
        } keyframes: { _ in
            SpringKeyframe(0.95, duration: 0.2, spring: .snappy)
            SpringKeyframe(1.0, duration: 0.2, spring: .bouncy)
        }
    }
}

/// A modifer that performs a ripple effect to its content whenever its
/// trigger value changes.
struct RippleEffect<T: Equatable>: ViewModifier {
    var origin: CGPoint

    var trigger: T

    init(at origin: CGPoint, trigger: T) {
        self.origin = origin
        self.trigger = trigger
    }

    func body(content: Content) -> some View {
        let origin = origin
        let duration = duration

        content.keyframeAnimator(
            initialValue: 0,
            trigger: trigger
        ) { view, elapsedTime in
            view.modifier(RippleModifier(
                origin: origin,
                elapsedTime: elapsedTime,
                duration: duration
            ))
        } keyframes: { _ in
            MoveKeyframe(0)
            LinearKeyframe(duration, duration: duration)
        }
    }

    var duration: TimeInterval { 3 }
}

/// A modifier that applies a ripple effect to its content.
struct RippleModifier: ViewModifier {
    var origin: CGPoint

    var elapsedTime: TimeInterval

    var duration: TimeInterval

    var amplitude: Double = 12
    var frequency: Double = 15
    var decay: Double = 8
    var speed: Double = 1200

    func body(content: Content) -> some View {
        let shader = ShaderLibrary.Ripple(
            .float2(origin),
            .float(elapsedTime),

            // Parameters
            .float(amplitude),
            .float(frequency),
            .float(decay),
            .float(speed)
        )

        let maxSampleOffset = maxSampleOffset
        let elapsedTime = elapsedTime
        let duration = duration

        content.visualEffect { view, _ in
            view.layerEffect(
                shader,
                maxSampleOffset: maxSampleOffset,
                isEnabled: 0 < elapsedTime && elapsedTime < duration
            )
        }
    }

    var maxSampleOffset: CGSize {
        CGSize(width: amplitude, height: amplitude)
    }
}

extension View {
    func onPressingChanged(_ action: @escaping (CGPoint?) -> Void) -> some View {
        modifier(SpatialPressingGestureModifier(action: action))
    }
}

struct SpatialPressingGestureModifier: ViewModifier {
    var onPressingChanged: (CGPoint?) -> Void

    @State var currentLocation: CGPoint?

    init(action: @escaping (CGPoint?) -> Void) {
        self.onPressingChanged = action
    }

    func body(content: Content) -> some View {
        let gesture = SpatialPressingGesture(location: $currentLocation)

        content
            .gesture(gesture)
            .onChange(of: currentLocation, initial: false) { _, location in
                onPressingChanged(location)
            }
    }
}

struct SpatialPressingGesture: UIGestureRecognizerRepresentable {
    final class Coordinator: NSObject, UIGestureRecognizerDelegate {
        @objc
        func gestureRecognizer(
            _ gestureRecognizer: UIGestureRecognizer,
            shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
        ) -> Bool {
            true
        }
    }

    @Binding var location: CGPoint?

    func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
        Coordinator()
    }

    func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
        let recognizer = UILongPressGestureRecognizer()
        recognizer.minimumPressDuration = 0
        recognizer.delegate = context.coordinator

        return recognizer
    }

    func handleUIGestureRecognizerAction(
        _ recognizer: UIGestureRecognizerType, context: Context) {
            switch recognizer.state {
                case .began:
                    location = context.converter.localLocation
                case .ended, .cancelled, .failed:
                    location = nil
                default:
                    break
            }
        }
    }



#Preview {
    ContentView()
}

まとめ

はじめてiPod touchを触ったときに感じた画面を触る楽しさを思い出しました。
当時のUIも今のUIに引けを取らないですね。下記動画のミュージックのアルバムを触ってるだけで楽しかったです。オススメです。

最後に

私の働いている会社で経験の有無を問わず採用を行っています。
興味のある方は是非カジュアル面談から応募してみてください!

21
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?