【SwiftUI】 動的に増えるオブジェクトにジェスチャーを付ける

2021年1月28日

SwiftUI を触り始めたばかりではありますが、動的に増える view に対してジェスチャーを追加する方法が分からず検索しても view  を定義してからそこにジェスチャーを追加する方法くらいしかあまり見つからずチュートリアルとかも無さそうだったので、今回まとめてみたいと思います。

Swift UI で MVVM モデル

Swift UI はデータ(状態)を元に画面を表示してくれます。以前のViewController にはview もロジックも書くことが可能でしたが、Swift UI でレンダリングする Struct のbody には基本的に view を返すものしか記述できません。そのため、ある view にジェスチャーを追加してドラッグ出来る様にするには以下の様に実装します。

import SwiftUI

struct TestView: View {
    @State var position: CGSize = CGSize(width: 200, height: 300)
        
        var drag: some Gesture {
            DragGesture()
            .onChanged{ value in
                self.position = CGSize(
                    width: value.startLocation.x
                        + value.translation.width,
                    height: value.startLocation.y
                        + value.translation.height
                )
            }
            .onEnded{ value in
                self.position = CGSize(
                    width: value.startLocation.x
                        + value.translation.width,
                    height: value.startLocation.y
                        + value.translation.height
                )
            }
            
        }
        
        var body: some View {
     
            Image("hoge")
                .position(x: position.width, y: position.height)
                .gesture(drag)
            
        }

}

ですがこれだと予め宣言していた Image 一つにしかジェスチャーを付けられません。このイメージをボタンを押すたびに増やしてそれぞれにジェスチャーを追加したい場合はどうすれば良いか少し悩みました。

しかしこれは MVVM の形で UIImage や position を持つモデルを VM で増やしたり位置を変えたりする事で実装可能でした。

Model

import UIKit

struct Pictures {
    
    var pictures = [Picture]()
    
    struct Picture: Identifiable, Hashable {
        let id: Int
        var x: CGFloat
        var y: CGFloat
        var picture: UIImage
        
        fileprivate init(picture: UIImage, x: CGFloat, y: CGFloat, id: Int) {
            self.picture = picture
            self.x = x
            self.y = y
            self.id = id
        }
    }
    
    private var uniquePictureId = 0
    
    mutating func addPicture(_ picture: UIImage, x: CGFloat, y: CGFloat) {
        uniquePictureId += 1
        pictures.append(Picture(picture: picture, x: x, y: y, id: uniquePictureId))
    }
    
}

View Model

import UIKit

class PictureViewModel: ObservableObject {
    
    @Published private var model: Pictures = Pictures()
    
    var pictures: [Pictures.Picture] {model.pictures}
    
    func addpicture(_ picture: UIImage, at location: CGSize) {
        model.addPicture(picture, x: location.width, y: location.height)
    }
    
    func movepicture(_ picture: Pictures.Picture, by offset: CGSize) {
        if let index = model.pictures.firstIndex(of: picture) {
            model.pictures[index].x += offset.width
            model.pictures[index].y += offset.height
        }
    }
}

View

import SwiftUI

struct ContentView: View {
    @ObservedObject var picturesVM: PictureViewModel
    var body: some View {
        
        VStack {
            Button(action: {
                self.picturesVM.addpicture(UIImage(imageLiteralResourceName: "hoge"), at: CGSize(width: 100, height: 200))
                self.picturesVM.addpicture(UIImage(imageLiteralResourceName: "hoge2"), at: CGSize(width: 100, height: 200))
            }){
                Image(systemName:"photo.on.rectangle")
            }.padding(.trailing)
            
            ForEach(self.picturesVM.pictures) { picture in
                
                Image(uiImage: picture.picture)
                    .resizable()
                    .scaledToFit()
                    .gesture(dragPicture(picture: picture))
                    .position(x: picture.x, y: picture.y)
            }
        }
        
    }
    
    func dragPicture(picture: Pictures.Picture) -> some Gesture {
        DragGesture()
            .onChanged{ value in
                self.picturesVM.movepicture(picture, by: CGSize(
                    width: value.translation.width,
                    height: value.translation.height
                ))
            }
            .onEnded{ value in
                self.picturesVM.movepicture(picture, by: CGSize(
                    width: value.translation.width,
                    height: value.translation.height
                ))
            }
    }

}

ObservableObject プロトコルを実装しているクラスのインスタンスを view 側で @ObservedObject を付けて持つ事でデータの変化を view の方で監視してくれます。

つまりviewModel で Model のリストを増やしたり位置を変えたりするとそれが view 側でレンダリングされるという事です。

以上で動的に増えるオブジェクトにジェスチャーを追加してドラッグする事ができました。

Swift UI 入門に


詳細!SwiftUI iPhoneアプリ開発入門ノート iOS 13+Xcode 11対応【電子書籍】[ 大重美幸 ]

SwiftUI 徹底入門 [ 金田 浩明 ]

[増補改訂第3版]Swift実践入門 ── 直感的な文法と安全性を兼ね備えた言語 [ 石川 洋資、西山 勇世 ]