[iOS][Swift] 画像の上でルーペ拡大部分が動くアニメーション

撮影した画像を画像解析にかけるアプリを作った時、解析中（通信中）にアクティビティインジケータ表示だけだとあまりにも簡素でつまらない、それとバックエンドでは解析後に結果を元に他のサービスと紐付けしたりしているためレスポンスがそこそこかかる。
アクティビティインジケータ表示と併用する形でユーザの待ち時間を多少ごまかせるような画像解析中アニメーションをサクッと実装した。

![magnification_effect.gif](https://qiita-image-store.s3.amazonaws.com/0/156085/e098e722-d254-82a5-c41b-2fb47a619a88.gif)

# 解析中の表現
画像解析中ってUX的にどんな表現だろうか。
API側がどのようなロジックで解析しているか分からないし、それっぽいのを考えるしかない。

- 画像にモザイクをかけてだんだん解消していくような感じ
→ ループできないためNG

- 画像の端から端にピクセル改竄して行くような感じ
→ ループできないためNG

- 横線や縦線、矩形が動き回るようなよく映画やドラマでみるような演出
→ 安っぽくなりそう

そういえばこのアプリの画像解析機能のアイコンを作った時に"画像"と"虫眼鏡（ルーペ）"をモチーフにピクトグラムを作ったことを思い出した。解析にかけている画像の上でルーペのような拡大表示円がランダムに動いたら解析っぽいしデザイン的にも一貫しているかも、ということで作ってみた。

# ルーペの動き
完全にランダムなポイントに動いてしまうと画像の端っこばかり移動したり同じような箇所ばかり動いてしまい、まるで解析が芯食ってないように見えてしまうので、ある程度画像の真ん中に集中し満遍なく移動するロジックにした。

画像の中心始まりにして、いくつかの中心に偏ったポイントを定義して、その間をランダムなスピードで動くようにした。

```Swift
// Returns random point
func randomPosition() -> CGPoint {
    let size = CGSize(width: magnificationImageRect.width, height: magnificationImageRect.height)
    let xPoints: [CGFloat] = [size.width * 0.2, size.width * 0.35, size.width * 0.45, size.width * 0.55, size.width * 0.65, size.width * 0.8]
    let yPoints: [CGFloat] = [size.height * 0.2, size.height * 0.35, size.height * 0.45, size.height * 0.55, size.height * 0.65, size.height * 0.8]
    let x = xPoints[Int(arc4random() % UInt32(xPoints.count))]
    let y = yPoints[Int(arc4random() % UInt32(yPoints.count))]
    return CGPoint(x: x, y: y)
}
```

またランダムが故発生する、前のポイントと同じポイントは採用しないロジックを入れた。

```Swift
repeat {
    nextPosition = randomPosition()
} while nextPosition == position
```

通信時間はわからないので一回で終わるアニメーションじゃダメでループ再生できないとならない。ループしても不自然じゃないように最初と最後は同じポイントとしている。

```Swift
// Back to initial position for loop
let lastAnimation = appendAnimation(position: position, nextPosition: initialPosition, totalDuration: totalDuration)
```

# 構造
ビュー構造は下の層から、

* 解析対象画像 UIImageView
* ラッパー UIView
    * 拡大した解析対象画像 UIImageView
        * ルーペ部分 CALayer

```Swift
private let imageView = UIImageView()
private let magnificationView = UIView()
private let magnificationImageView = UIImageView()
private let magnificationMaskLayer = CALayer()
```
ルーペ部分で拡大画像をマスクして動かすという寸法。
ラッパーは元の解析対象画像を超えないように内容をクリップしている。

```Swift
override init(frame: CGRect) {
    super.init(frame: frame)
    imageView.image = UIImage(named: "cancun_pool_bar.png")
    addSubview(imageView)

    magnificationView.isHidden = true
    magnificationView.clipsToBounds = true
    addSubview(magnificationView)

    magnificationImageView.image = imageView.image
    magnificationView.addSubview(magnificationImageView)
    
    magnificationMaskLayer.backgroundColor = UIColor.black.cgColor
    magnificationMaskLayer.cornerRadius = 100
    magnificationMaskLayer.frame.size = CGSize(width: 200, height: 200)
    magnificationImageView.layer.mask = magnificationMaskLayer

    imageView.snp.makeConstraints { (make) in
        make.center.size.equalToSuperview()
    }

    magnificationView.snp.makeConstraints { (make) in
        make.center.size.equalTo(imageView)
    }

    magnificationImageView.snp.makeConstraints { (make) in
        make.center.equalToSuperview()
        make.size.equalToSuperview().multipliedBy(2.0)
    }
}
```
定義したポイントから10個ランダムに選出して `magnificationMaskLayer` に `CABasicAnimation` を追加している。

```Swift
// Start from center
var position: CGPoint = CGPoint(x: magnificationImageRect.width / 2, y: magnificationImageRect.height / 2)
let initialPosition: CGPoint = position
var totalDuration = 0.0
for _ in 0...10 {
    var nextPosition: CGPoint
    repeat {
        nextPosition = randomPosition()
    } while nextPosition == position
    let animation = appendAnimation(position: position, nextPosition: nextPosition, totalDuration: totalDuration)
    position = nextPosition
    totalDuration += animation.duration
}
// Back to initial position for loop
let lastAnimation = appendAnimation(position: position, nextPosition: initialPosition, totalDuration: totalDuration)
totalDuration += lastAnimation.duration

let group = CAAnimationGroup()
group.animations = animations
group.duration = totalDuration
group.repeatCount = Float.infinity
magnificationMaskLayer.add(group, forKey: "positionAnimation")
```

```Swift
func appendAnimation(position: CGPoint, nextPosition: CGPoint, totalDuration: CFTimeInterval) -> CABasicAnimation {
    let animation = CABasicAnimation(keyPath: "position")
    animation.fromValue = position
    animation.toValue = nextPosition
    animation.duration = 1.0 + CFTimeInterval(arc4random() % UInt32(2))
    animation.beginTime = totalDuration
    animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
    animation.isRemovedOnCompletion = false
    animation.fillMode = kCAFillModeForwards
    animations.append(animation)
    return animation
}
```
お客さんにはそこそこウケたので良かった！

# ソースコード
https://github.com/atsushijike/AnalyzeImageView
環境
- Xcode 9.3
- Swift 4.1