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

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

https://developer.apple.com/videos/play/wwdc2024/10151/

# 前提条件
- Xcode 16
- iOS 18

- Assetsに適当な画像を追加
![スクリーンショット 2024-06-25 17.35.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/48996107-0651-554c-99c2-b5d2a8e74b40.png)






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

## Scroll effects

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

| エフェクトなし | エフェクト1 | エフェクト2 |
| :---: | :---: | :---:|
| ![0_0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/23242fc9-251b-ba9f-f3e9-29064fb756f0.gif) | ![1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/aff6a8b4-acd7-df25-fe52-4faaa1bb50f9.gif) | ![2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/a5a64438-fb9f-4a20-4d1a-e09b6bd5d395.gif)


<details><summary>スクロールエフェクトなし</summary>

```Swift
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))
    }
}

``` 
</details>


<details>
<summary>スクロールエフェクト1</summary>

```Swift
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))
    }
}
```
</details>

<details><summary>スクロールエフェクト2</summary>

```Swift
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)
    }
}
```
</details>


| 色合い変更なし | 色合い変更1 | 色合い変更2 |
| :---: | :---: | :---:|
| ![1_0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/6e7b5102-54b0-9c4e-8d80-7ff694e1f2fd.gif) | ![1_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/c6b60743-005d-ae24-48ae-353115e4243c.gif) | ![1_2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/a2c4b150-58fb-ef48-51fc-c6801d853b7d.gif) |


<details><summary>色合い変更なし</summary>

```Swift
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()
}
``` 
</details>

<details><summary>色合い変更1</summary>

```Swift
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()
}
``` 
</details>

<details><summary>色合い変更2</summary>

```Swift
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()
}
``` 
</details>



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

https://developer.apple.com/documentation/swiftui/lineargradient

| メッシュグラディエント1 | メッシュグラディエント2 | メッシュグラディエント3 |
| :---: | :---: | :---:|
| ![2_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/4c30dbd0-4fcd-c137-c342-4eb51d8d81ed.png) | ![2_2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/1ec55535-816b-5378-375a-a13d7941a43a.gif) | ![2-3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/715cdf4c-40b6-2a97-800a-3b1fe7c073b5.gif) |


<details><summary>メッシュグラディエント１</summary>

```Swift
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()
}

``` 
</details>


<details><summary>メッシュグラディエント2</summary>

```Swift
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()
}

``` 
</details>


<details><summary>メッシュグラディエント2</summary>

```Swift

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()
}

``` 
</details>



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

| ビュートランジション1 | ビュートランジション2 |
| :---: | :---: |
| ![4_0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/f93c8ab9-bcbe-e1d8-0055-8105bc51bf84.gif) | ![4_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/493afba1-3f3a-a9e8-d5d4-b49dd55e49fe.gif) |


<details><summary>ビュートランジション1</summary>

```Swift
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()
}
``` 
</details>


<details><summary>ビュートランジション2</summary>

```Swift
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()
}
``` 
</details>








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

| テキストトランジション1 | テキストトランジション2 |
| :---: | :---: |
| ![5_0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/3326296e-5496-4ae2-fc29-d6c01a1d673d.gif) | ![5_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/6bc3e1a2-68ce-c298-269f-23d8229ac678.gif) |



<details><summary>テキストトランジション1</summary>

```Swift
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()
}

``` 
</details>


<details><summary>テキストトランジション2</summary>

```Swift
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()
}

``` 
</details>





## Metal shaders🤘

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

https://developer.apple.com/documentation/metalperformanceshaders

| メタルシェーダー1 | メタルシェーダー2 |
| :---: | :---: |
| ![6_0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/666dfcd7-17b4-a871-0543-a1a37686224e.gif) | ![6_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/a0314fb1-93d0-0dce-9334-af9091e07df2.gif) |


<details><summary>メタルシェーダー1</summary>

```Swift
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()
}

``` 
</details>


<details><summary>メタルシェーダー2</summary>

```Swift
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()
}

``` 
</details>





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

https://youtu.be/mwfs2l3Vfug?si=D2FkbwE7XO1GKp6E&t=241


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

https://jambo-support.com/recruit_engineer/