Transformを使用して簡単に視差効果を表現する


今回はアプリの初回チュートリアルなどでスクロールをカッコよく見せる演出として使われるパララックス（視差効果）を表現する実装をご紹介します。
今回のものはサンプルなので見た目はイマイチですが、参考程度にどうぞ。

# 完成イメージ
白い正方形のViewには視差効果を与えておらず、1ページ目の青いLabel、2ページ目の黒いLabelに視差効果を与えています。
青のLabelと白いViewのスクロール量が少しずれていることがわかると思います。

<img src="https://i.imgur.com/tEsDGaH.gif" width="300">

# 実装概要
- UIScrollViewでページングをする横スクロールを作成します
- UIScrollView内にはUIStackViewを配置し、任意のページ分のコンテンツを追加してスクロールできるようにします。
- 各ページのUIViewのtransform, alphaにアクセスして、スクロール量に応じて値を変化させます。

## View階層
<img src="https://i.imgur.com/tRTy728.png" width="300">

# 実装
今回はUI実装の簡略化のためにSnapKitというライブラリを使用させていただきます。

```swift
import UIKit
import SnapKit

final class ParallaxViewController: UIViewController {
    let scrollView: UIScrollView = {
        let scroll = UIScrollView()
        scroll.isPagingEnabled = true
        return scroll
    }()

    let stackView: UIStackView = {
        let stack = UIStackView()
        stack.axis = .horizontal
        stack.alignment = .fill
        stack.backgroundColor = .purple
        return stack
    }()

    let containers: [LabelContainer] = [
        LabelContainer(labelColor: .blue),
        LabelContainer(labelColor: .black)
    ]

    private var contentOffsetObservation: NSKeyValueObservation?

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white

        configureScrollView()

        contentOffsetObservation = scrollView.observe(\.contentOffset) { [weak self] scrollView, _ in
            guard let me = self else { return }

            me.containers.enumerated().forEach { index, container in
                let distanceContainerToLabel = container.frame.origin.x - scrollView.contentOffset.x
                // スクロール量に応じてLabelの表示位置をずらす
                container.label.transform = .init(
                    translationX: distanceContainerToLabel * -0.3,
                    y: .zero
                )

                let limit = me.scrollView.bounds.width
                // スクロール量に応じてLabelの透明度を変化させる
                container.alpha = {
                    switch distanceContainerToLabel {
                    case -limit..<0, 0..<limit:
                        return 1 - abs(distanceContainerToLabel) / limit
                    default:
                        return .zero
                    }
                }()
            }
        }
    }

    private func configureScrollView() {
        view.addSubview(scrollView)
        scrollView.snp.makeConstraints {
            $0.top.left.right.equalToSuperview()
            $0.height.equalTo(300)
        }

        scrollView.addSubview(stackView)
        stackView.snp.makeConstraints {
            $0.edges.height.equalToSuperview()
        }

        containers.forEach { container in
            stackView.addArrangedSubview(container)
            container.snp.makeConstraints {
                $0.width.equalTo(view.snp.width)
            }
        }

        let box = UIView()
        box.backgroundColor = .white
        scrollView.addSubview(box)
        box.snp.makeConstraints {
            $0.width.height.equalTo(200)
            $0.center.equalToSuperview()
        }
    }
}

final class LabelContainer: UIView {
    let label: UILabel

    init(labelColor: UIColor) {
        label = makeLabel(color: labelColor)
        super.init(frame: .zero)

        addSubview(label)
        label.snp.makeConstraints { $0.edges.equalToSuperview() }
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

}

fileprivate func makeLabel(color: UIColor) -> UILabel {
    let label = UILabel()
    label.text = Array(repeating: "LABEL", count: 100).joined()
    label.textAlignment = .center
    label.numberOfLines = 0
    label.textColor = .white
    label.backgroundColor = color
    return label
}
```