【SwiftUI】iPhoneのホーム画面のアプリのような並び替えを実装する

# はじめに
Twitterを見てたら、iPhoneのホーム画面のアプリのような並び替えどうやってやるんだろう的なツイートが流れてきて気になったので実装してみました。

# サンプルアプリ
![Simulator Screen Recording - iPhone 15 Pro - 2024-02-18 at 22.30.53.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1745371/78c7ac9a-74e0-93d7-8b26-9f64f3e09ffb.gif)

# データ構造体
Colorを使いたかったのですが、Codableに準拠してなくて、ちょっとめんどそうだったので、RGBそれぞれをDoubleで持っています。
```swift
struct AppIcon: Codable, Identifiable {
    var id = UUID()
    let red: Double
    let green: Double
    let blue: Double
}

extension AppIcon: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(for: AppIcon.self, contentType: .application)
    }
}
```

# 実装
```swift
import SwiftUI

struct ContentView: View {
    /// 現在ドラッグしているアプリアイコン
    @State private var draggingAppIcon: AppIcon?
    /// アプリアイコン一覧
    @State private var appIcons: [AppIcon] = [
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
        .init(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
    ]
    /// アプリアイコンの丸角サイズ
    private let appCornerRadius = 10.0
    /// ホーム画面のグリッドカラム
    private let columns = Array(repeating: GridItem(.flexible(), spacing: 10), count: 4)

    var body: some View {
        LazyVGrid(columns: columns, spacing: 10) {
            ForEach(appIcons) { appIcon in
                appIconView(color: .init(red: appIcon.red, green: appIcon.green, blue: appIcon.blue))
                    .draggable(appIcon) {
                        appIconView(color: .init(red: appIcon.red, green: appIcon.green, blue: appIcon.blue))
                            .contentShape(.dragPreview, .rect(cornerRadius: appCornerRadius))
                            .onAppear {
                                draggingAppIcon = appIcon
                            }
                    }
                    .dropDestination(for: AppIcon.self) { _, _ in
                        true
                    } isTargeted: { isTargeted in
                        // ターゲットがない時は何もしない
                        guard isTargeted else { return }
                        // ドラッグしてない時は何もしない
                        guard let draggingAppIcon else { return }
                        // ドラッグ中のIDとターゲットのIDが一緒であれば何もしない
                        guard draggingAppIcon.id != appIcon.id else { return }
                        // ドラッグ中のインデックス
                        guard let draggingAppIconIndex = appIcons.firstIndex(where: { $0.id == draggingAppIcon.id }) else {
                            return
                        }
                        // ターゲットのインデックス
                        guard let targetedAppIconIndex = appIcons.firstIndex(where: { $0.id == appIcon.id }) else {
                            return
                        }
                        
                        withAnimation {
                            let sourceItem = appIcons.remove(at: draggingAppIconIndex)
                            appIcons.insert(sourceItem, at: targetedAppIconIndex)
                        }
                    }
            }
        }
        .padding(.horizontal, 20)
    }
    
    private func appIconView(color: Color) -> some View {
        RoundedRectangle(cornerRadius: appCornerRadius)
            .frame(width: 70, height: 70)
            .foregroundStyle(color)
            .shadow(radius: 1)
    }
}
```

# おわり
iOS16からドラッグ&ドロップの実装方法がかなり変わったようです。
それまではDropDelegateを使用して実装する感じだったらしいです。


# 参考記事
https://zenn.dev/oka_yuuji/articles/dc308f8fc9ae10

https://zenn.dev/zunda_pixel/articles/7dd3a47b0a3998

https://youtu.be/UFiOCcm6zTo