【Swift】ScrollViewとImageViewを使ってスクロール・ズーム・クロップ機能を実装してみた（LINEのアイコン登録機能風）

#はじめに

個人アプリ作成時に本記事のタイトルの機能を実装したので、
備忘録がてらコマンドの共有をさせていただきます。

初心者が作ったコードなのでツッコミどころが満載かもしれませんがお許しください。

今回は、LINEのアイコンを変更する時と似ているものを作ってみたつもりです。

全体のコードと各コードの説明の部分でいくつか差分があるかもしれません・・・。
今現在動いている内容は全体コードの内容になります。


環境
・Swift version 5.3
・XCode version 12.3

#完成形

![ezgif.com-gif-maker (2) (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/797483/fe1e9941-c6ab-89dc-0bc7-4e2309ec29c6.gif)


#コード

```swift:ViewController.swift
import UIKit

class ViewController: UIViewController, UIScrollViewDelegate {

    private let imageView = UIImageView()
    private let scrollView = UIScrollView()
    private var cropView = UIView()
    var imageInfo: UIImage?
    
    let width = UIScreen.main.bounds.width
    let height = UIScreen.main.bounds.height
    
    var beforeVC = "ChangeProfileVC"
    
    var cropArea: CGRect {
        return CGRect(
            x: 15,
            y: height / 2 - (width - 30) / 2,
            width: width - 30,
            height: width - 30)
    }
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setUp()
        addCropView()
        
        //ボーダーが必要な場合はコメントアウト解除
//        setUpBorder()

    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        navigationController?.navigationBar.isHidden = true
    }
    
    // imageViewとScrollViewのセットアップ
    func setUp(){
        guard let imageWidth = imageInfo?.size.width, let imageHeight = imageInfo?.size.height else {
            return
        }
        
        if imageWidth < imageHeight {
            //画像の横幅が縦幅より小さい時
            let ratio = imageHeight / imageWidth
            imageView.frame.size = CGSize(width: width - 30, height: (width - 30) * ratio)
            
            //スクロールする中身のサイズ
            scrollView.contentSize = imageView.frame.size
            // scrollViewの表示される領域
            scrollView.frame.size = CGSize(width: width - 30, height: width * 1.5 )
            scrollView.center = self.view.center
            
            //上下の余分にスクロールできる領域
            let topAndBottomInset = (scrollView.frame.height) / 2 - view.frame.width / 2 + 15
            // 余分にどれだけスクロールできるか
            scrollView.contentInset = UIEdgeInsets(
                top: topAndBottomInset,
                left: 0,
                bottom: topAndBottomInset,
                right: 0
            )
            
        } else {
            //画像の縦幅が横幅より小さい時
            let ratio = imageWidth / imageHeight
            imageView.frame.size = CGSize(width: (width - 30) * ratio, height: width - 30)
            
            scrollView.contentSize = imageView.frame.size
            scrollView.frame.size = CGSize(width: width, height: imageView.frame.height )
            scrollView.center = view.center
            
            scrollView.contentInset = UIEdgeInsets(
                top: 0,
                left: 15,
                bottom: 0,
                right: 15
            )
            
        }
        
        // imageViewにライブラリで選択した画像を入れる
        imageView.image = imageInfo
        // imageView全体に表示するように指定（比率が等しいので別にいらないかも）
        imageView.contentMode = .scaleAspectFit
        
        // 縦と横のインジケーターを削除
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        
        // ズームの倍率
        scrollView.maximumZoomScale = 2
        scrollView.minimumZoomScale = 1
        
        scrollView.delegate = self
        
        scrollView.addSubview(imageView)
        view.addSubview(scrollView)
    }
    
    // クロップ範囲を表示するViewの作成
    func addCropView() {
        cropView = UIView(frame: self.view.bounds)
        // 背景を黒にして透過させる
        cropView.backgroundColor = .black
        cropView.alpha = 0.5
        // ユーザの操作が効かないようにする
        cropView.isUserInteractionEnabled = false
        
        // クロップする範囲で図形を作る（丸型にする）
        let cropAreaPath = UIBezierPath(roundedRect: cropArea, cornerRadius: view.frame.width / 2)
        // 外枠の範囲で図形を作る（背景が黒になる範囲）
        let outsideCropAreaPath = UIBezierPath(rect: scrollView.frame)
        // クロップ範囲の丸型図形の下にViewの幅の図形を追加する
        cropAreaPath.append(outsideCropAreaPath)
        
        let cropAreaLayer = CAShapeLayer()
        // cropAreaPathの持つ図形のpathを渡す
        cropAreaLayer.path = cropAreaPath.cgPath
        // 枠全体の塗り潰し
        cropAreaLayer.fillColor = UIColor.black.cgColor
        // クロップするエリアの塗り潰し解除
        cropAreaLayer.fillRule = .evenOdd
        cropView.layer.mask = cropAreaLayer
        
        view.addSubview(cropView)
    }
    
    // クロップ範囲の枠を作成
    func setUpBorder() {
        let border = CAShapeLayer()
        let borderPath = UIBezierPath(rect: cropArea)
        border.path = borderPath.cgPath
        border.lineWidth = 1
        border.strokeColor = UIColor.white.cgColor
        cropView.layer.addSublayer(border)
    }
    
    // ズームのために要指定
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView
    }
    
    @IBAction func pushCropButton(_ sender: Any) {
        guard let image = imageInfo else {
            return
        }
        
        // クロップ領域の位置とサイズを取得
        let cropAreaRect = cropView.convert(cropArea, to: scrollView)
        
        // 画像がどのくらい拡大されたかの倍率
        let imageViewScale = max(
            image.size.width / imageView.frame.width,
            image.size.height / imageView.frame.height
        )
        
        // クロップする範囲を定義
        let cropZone = CGRect(
            x: (cropAreaRect.origin.x - imageView.frame.origin.x) * imageViewScale,
            y: (cropAreaRect.origin.y - imageView.frame.origin.y) * imageViewScale,
            width: cropAreaRect.width * imageViewScale,
            height: cropAreaRect.height * imageViewScale
        )
        
        let croppedCGImage = image.cgImage?.cropping(to: cropZone)
        
        if let croppedCGImage = croppedCGImage {
            // 元の画像の向きを元に向きを再調整する
            let croppedImage = UIImage(cgImage: croppedCGImage, scale: 0, orientation: image.imageOrientation)
            
            if beforeVC == "ChangeProfileVC" {
                let changeProfileVC = navigationController?.viewControllers.first(where: {$0 is ChangeProfileViewController}) as! ChangeProfileViewController
                changeProfileVC.customView.icon.image = croppedImage
            
                navigationController?.popToViewController(changeProfileVC, animated: true)
            } else if beforeVC == "ProfileModificationVC" {
                let profileModificationVC = navigationController?.viewControllers.first(where: {$0 is ProfileModificationViewController}) as! ProfileModificationViewController
                profileModificationVC.customView.icon.image = croppedImage
                
                navigationController?.popToViewController(profileModificationVC, animated: true)
            }
            
        }
    }
    
    // 戻るボタンが押された時の処理
    @IBAction func pushBackButton(_ sender: Any) {
        self.navigationController?.popViewController(animated: true)
    }
}

```

##コード説明
実装するにあたり、私が調べた（実装した）順に記載しております。

初歩的なことから記載していくので必要な箇所のみ参考にしてください。

##スクロール機能の実装

UIImageView型と、UIScrollView型の変数を宣言しておきます。
ついでによく使う縦と横の情報も宣言しておきます。

imageInfoは、フォトライブラリで選択した画像を格納するための変数です。
なので、特に宣言する必要はありません。

なお、フォトライブラリで画像を選択してimageInfoに渡す処理は記事の対象外の内容なので
説明は省略させていただきます。
実装したい方は記事の最後の「参考」にリンクを貼っていますのでそちらをご覧ください。

```swift:ViewController.swift
    private let imageView = UIImageView()
    private let scrollView = UIScrollView()
    var imageInfo: UIImage?
    
    let width = UIScreen.main.bounds.width
    let height = UIScreen.main.bounds.height
```

次にImageViewとScrollViewを表示させます。

画面取得時に`setUp()`メソッドを呼びframeやpositionを設定します。

setUp( )メソッドでは、画像のサイズを端末の大きさに合わせてリサイズしています。

`if imageWidth < imageHeight { }`で縦横どちらが大きいかを判断し、
その結果に応じた処理を行っています。

縦長の画像の場合は、縦横の比率を求めた後に、
画像の横幅を`端末の横幅 - 30`に、縦幅をその長さから求めています。

`scrollView.contentSize`はScrollViewの中身の大きさです。
contentSizeの分だけスクロールすることが出来ます。

私は、基本的に中身のViewと同じサイズにして、
`scrollView.contentInset`で余白を作っています。

今回は左右の余白は０にしました。
`scrollView.contentSize`で表示する大きさを決めているので、
特に変更する必要はありません。

ただ、中央で正方形に切り取る場合は、縦は余分を作らないといけません。

そのため、`topAndBottomInset`の部分で
上下の余分を取得し、contentInsetのtopとbottomに指定しています。

`(scrollView.frame.height) / 2 - view.frame.width / 2 + 15`
の計算式は次のようになります。

・`(scrollView.frame.height) / 2`
縦幅の中心の値
・`view.frame.width / 2`
切り取る正方形の中心から半分の縦幅(正方形なので横幅の値から割り出す)
・`15`
正方形は`width - 30`なので、左から15、右から15のスペース離れています。
なのでその分の15を足しています。


```swift:ViewController.swift

    override func viewDidLoad() {
        super.viewDidLoad()
       
        setUp()
        
        scrollView.addSubview(imageView)
        view.addSubview(scrollView)
    }
    
    // imageViewとScrollViewのセットアップ
    func setUp(){
        guard let imageWidth = imageInfo?.size.width, let imageHeight = imageInfo?.size.height else {
            return
        }
        
        if imageWidth < imageHeight {
            //画像の横幅が縦幅より小さい時
            let ratio = imageHeight / imageWidth
            imageView.frame.size = CGSize(width: width - 30, height: (width - 30) * ratio)
            
            //スクロールする中身のサイズ
            scrollView.contentSize = imageView.frame.size
            // scrollViewの表示される領域
            scrollView.frame.size = CGSize(width: width - 30, height: width * 1.5 )
            scrollView.center = self.view.center
            
            //上下の余分にスクロールできる領域
            let topAndBottomInset = (scrollView.frame.height) / 2 - view.frame.width / 2 + 15
            // 余分にどれだけスクロールできるか
            scrollView.contentInset = UIEdgeInsets(
                top: topAndBottomInset,
                left: 0,
                bottom: topAndBottomInset,
                right: 0
            )
            
        } else {
            //画像の縦幅が横幅より小さい時
            let ratio = imageWidth / imageHeight
            imageView.frame.size = CGSize(width: (width - 30) * ratio, height: width - 30)
            
            scrollView.contentSize = imageView.frame.size
            scrollView.frame.size = CGSize(width: width, height: imageView.frame.height )
            scrollView.center = view.center
            
            scrollView.contentInset = UIEdgeInsets(
                top: 0,
                left: 15,
                bottom: 0,
                right: 15
            )
            
        }
        
        // imageViewにライブラリで選択した画像を入れる
        imageView.image = imageInfo
        // imageView全体に表示するように指定（比率が等しいので別にいらないかも）
        imageView.contentMode = .scaleAspectFit
        
        // 縦と横のインジケーターを削除
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
    }
```

ここまで記述すると、画像をスクロールするところまで実装出来ます。
正方形の範囲内に収まるように余分をうまく調整出来ているはずです。

また、横長の画像の場合も問題なく機能することを確認してみてください。

##ズーム機能の実装

viewDidLoad( )内に`scrollView.delegate = self`を追加し
デリゲートの処理を委譲します。

この時エラーが出ると思うのでFIXしてください。
するとクラスが`UIScrollViewDelegate`に準拠する形になります。

次にsetUp( )内に`maximumZoomScale`と`minimumZoomScale`を追記します。
これはズームインの倍率とズームアウトの倍率です。

今回は２倍までズームインできるようにします。

最後にズームを行うためのデリゲートメソッドを追加します。

```swift:ViewController.swift

    override func viewDidLoad() {
        super.viewDidLoad()
       
        scrollView.delegate = self

    }

    // imageViewとScrollViewのセットアップ
    func setUp(){
        
        // 縦と横のインジケーターを削除
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        
        // ズームの倍率
        scrollView.maximumZoomScale = 2
        scrollView.minimumZoomScale = 1
        
    }

    // ズームのために要指定
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView
    }
```

ここまで記述すればズーム機能も実装できると思います！


##アイコン表示範囲外にマスクの設置

クロップの機能の前に、クロップ範囲外に半透明のマスクをかけようと思います。
マスクがあることにより、どの部分がアイコンに使われるか分かりやすくなります。

viewDidLoad( )に`addCropView()`を追加します。

addCropView( )メソッドはクロップの範囲外以外を塗りつぶすメソッドです。

流れとしては次のようになります。
**① Viewを作成する**
Viewの大きさは画面全体で背景色を黒にする（透過 0.5）

**②クロップ範囲の定義**
クロップする範囲と、外枠の範囲を定義する

**③塗り潰し（または解除）**
②で定義した範囲をもとに、クロップ範囲外の塗り潰しとクロップ範囲の塗り潰しを解除する

cropAreaプロパティは、クロップする範囲を返すプロパティです。
クロップ範囲は何回か記述する予定なので先に記述しておきます。

```swift:ViewController.swift

class TrimmingViewController: UIViewController, UIScrollViewDelegate {

    private var cropView = UIView()

    var cropArea: CGRect {
        return CGRect(
            x: 15,
            y: height / 2 - (width - 30) / 2,
            width: width - 30,
            height: width - 30)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
       
        setUp()
        addCropView()

    }

    // クロップ範囲を表示するViewの作成
    func addCropView() {
        cropView = UIView(frame: self.view.bounds)

        //================   ①  ==================
        // 背景を黒にして透過させる
        cropView.backgroundColor = .black
        cropView.alpha = 0.5
        // ユーザの操作が効かないようにする
        cropView.isUserInteractionEnabled = false
        
        //================   ②  ==================
        // クロップする範囲（丸型にする）
        let cropAreaPath = UIBezierPath(roundedRect: cropArea, cornerRadius: view.frame.width / 2)
        // 外枠の範囲で図形を作る（背景が黒になる範囲）
        let outsideCropAreaPath = UIBezierPath(rect: scrollView.frame)
        
        cropAreaPath.append(outsideCropAreaPath)
        
        //================   ③  ==================
        let cropAreaLayer = CAShapeLayer()
        // cropAreaPathの持つ範囲を渡す
        cropAreaLayer.path = cropAreaPath.cgPath
        // 枠全体の塗り潰し
        cropAreaLayer.fillColor = UIColor.black.cgColor
        // クロップするエリアの塗り潰し解除
        cropAreaLayer.fillRule = .evenOdd
        cropView.layer.mask = cropAreaLayer
        
        view.addSubview(cropView)
    }
}
```

##クロップ範囲を白枠で囲む
ここまでのコードを記述するとアイコンにする範囲が表示され、
それ以外の範囲は半透明の黒で塗り潰しされています。

実際にクロップする範囲は、丸型ではなく長方形です。
ラインのアイコンも丸で表示されますが、表示すると長方形のはずです。

なので、次に実際にクロップする範囲を表示していきます。

クロップの範囲は、先ほどのマスクを作成した時の処理と似ています。
`let borderPath = UIBezierPath(rect: cropArea)`で正方形の定義

`border.path = borderPath.cgPath`でborderに情報を入れています。
その後、borderの枠の太さを１にし、色を白に変更してcropViewに追加します。

```swift:ViewController.swift

    override func viewDidLoad() {
        super.viewDidLoad()

        setUpBorder()
    }

    // クロップ範囲の枠を作成
    func setUpBorder() {
        let border = CAShapeLayer()
        let borderPath = UIBezierPath(rect: cropArea)
        border.path = borderPath.cgPath
        border.lineWidth = 1
        border.strokeColor = UIColor.white.cgColor
        cropView.layer.addSublayer(border)
    }
```

##画像をクロップする



#さいごに


#参考

・[【Swift】アルバムとカメラを起動する](https://nekokichi2yos2.hatenablog.com/entry/2019/02/04/110447)
・[【iOS】 ズーム＆スクロールして画像をクロップできる機能を実装する（Slackのアバター画像登録機能っぽく作ってみた）](https://qiita.com/takehilo/items/17ee84059332650a21a7)
・[【Swift】UIImageからCGImageを作成すると、画像が90度など回転してしまう件への対策](https://www.yoheim.net/blog.php?q=20190102)