[iOS] ページングできる画像ビュアーを作る

はじめに

モバイルアプリサービス部の中安です。

画像コンテンツのあるアプリの要件には、それらを閲覧できる画像ビュアーが求められることがあります。 それは単数の画像であることもあるし、複数の画像をスワイプでページング(指でひゅんひゅん)できることが求められることもあります。

世の中には便利で高機能なそういったライブラリもありますが、 簡易的なものであればそれほど複雑なソースコードを書かなくても自分で実現できます。

今回はそんなページング可能な複数の画像を見るためのビュアー画面の作成をやってみたいと思います。

完成イメージ

今回の完成イメージはこのようなものです。

※冒頭のクマは無関係です

動きをまとめるとこんな感じです

  • スワイプによるページングができる
  • ピンチイン/アウトで画像の拡大ができる
  • ダブルタップで画像拡大され、もう一度ダブルタップすると等倍に戻る
  • ページが変わるとページコントロールが更新される
  • ページコントロールによってページを遷移できる
  • 別画像にベージングされた時点で元のページの拡大率は元に戻る

こんな自分も本田翼をグググッと拡大させたいし、橋本環奈もトントンしたいわけです。

前準備

ビューコントローラの基本定義

まずビューコントローラの定義を書きます。クラス名はデフォルトで用意されている ViewController です。

import UIKit

class ViewController: UIViewController {
    
    @IBOutlet private weak var mainScrollView: UIScrollView!
    @IBOutlet private weak var pageControl: UIPageControl!
}

このように @IBOutlet にする部品は2つ。「メインとなるスクロールビュー」と「ページコントロール」です。

ストーリーボード

ストーリーボードはこのような配置にします。

画像

今回はサンプルとして画像を10枚用意しました。0.jpg1.jpg2.jpg・・・という名前で9.jpgまで用意し、アセットの中に放り込んでいます。

実装

まずは長くなりますが全体のソースコードを載せます。 そのあとにいろいろと細かくチェックポイントを書こうかと思います。

前準備が済んでいれば、まずはコピペしてもらうと動くのではないかと思います。

import UIKit

class ViewController: UIViewController {
    
    /// ページ数(サンプルのため固定)
    private let numberOfPages = 10
    
    @IBOutlet private weak var mainScrollView: UIScrollView!
    @IBOutlet private weak var pageControl: UIPageControl!
    
    /// 現在のページインデックス
    private var currentPage = 0

    override func viewDidLoad() {
        super.viewDidLoad()
        setupPageControl()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        setupMainScrollView()
        (0..<numberOfPages).forEach { page in
            let subScrollView = generateSubScrollView(at: page)
            mainScrollView.addSubview(subScrollView)
            let imageView = generateImageView(at: page)
            subScrollView.addSubview(imageView)
        }
    }
    
    private func setupPageControl() {
        pageControl.numberOfPages = numberOfPages
        pageControl.currentPage = currentPage
        // タップされたときのイベントハンドリングを設定
        pageControl.addTarget(
            self,
            action: #selector(didValueChangePageControl),
            for: .valueChanged
        )
    }
    
    private func setupMainScrollView() {
        mainScrollView.delegate = self
        mainScrollView.isPagingEnabled = true
        mainScrollView.showsVerticalScrollIndicator = false
        mainScrollView.showsHorizontalScrollIndicator = false
        // コンテンツ幅 = ページ数 x ページ幅
        mainScrollView.contentSize = CGSize(
            width: calculateX(at: numberOfPages),
            height: mainScrollView.bounds.height
        )
    }
    
    private func generateSubScrollView(at page: Int) -> UIScrollView {
        let frame = calculateSubScrollViewFrame(at: page)
        let subScrollView = UIScrollView(frame: frame)
        
        subScrollView.delegate = self
        subScrollView.maximumZoomScale = 3.0
        subScrollView.minimumZoomScale = 1.0
        subScrollView.showsHorizontalScrollIndicator = false
        subScrollView.showsVerticalScrollIndicator = false
        
        // ダブルタップされたときのイベントハンドリングを設定
        let gesture = UITapGestureRecognizer(target: self, action: #selector(didDoubleTapSubScrollView(_:)))
        gesture.numberOfTapsRequired = 2
        subScrollView.addGestureRecognizer(gesture)
        
        return subScrollView
    }
    
    private func generateImageView(at page: Int) -> UIImageView {
        let frame = mainScrollView.bounds
        let imageView = UIImageView(frame: frame)
        
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        imageView.image = image(at: page)
        
        return imageView
    }
    
    /// ページコントロールを操作された時
    @objc private func didValueChangePageControl() {
        currentPage = pageControl.currentPage
        let x = calculateX(at: currentPage)
        mainScrollView.setContentOffset(CGPoint(x: x, y: 0), animated: true)
    }
    
    /// サブスクロールビューがダブルタップされた時
    @objc private func didDoubleTapSubScrollView(_ gesture: UITapGestureRecognizer) {
        guard let subScrollView = gesture.view as? UIScrollView else { return }
        
        if subScrollView.zoomScale < subScrollView.maximumZoomScale {
            // タップされた場所を中心に拡大する
            let location = gesture.location(in: subScrollView)
            let rect = calculateRectForZoom(location: location, scale: subScrollView.maximumZoomScale)
            subScrollView.zoom(to: rect, animated: true)
        } else {
            subScrollView.setZoomScale(subScrollView.minimumZoomScale, animated: true)
        }
    }
    
    /// ページ幅 x position でX位置を計算
    private func calculateX(at position: Int) -> CGFloat {
        return mainScrollView.bounds.width * CGFloat(position)
    }
    
    /// スクロールビューのオフセット位置からページインデックスを計算
    private func calculatePage(of scrollView: UIScrollView) -> Int {
        let width = scrollView.bounds.width
        let offsetX = scrollView.contentOffset.x
        let position = (offsetX - (width / 2)) / width
        return Int(floor(position) + 1)
    }
    
    /// タップされた位置と拡大率から拡大後のCGRectを計算する
    private func calculateRectForZoom(location: CGPoint, scale: CGFloat) -> CGRect {
        let size = CGSize(
            width: mainScrollView.bounds.width / scale,
            height: mainScrollView.bounds.height / scale
        )
        let origin = CGPoint(
            x: location.x - size.width / 2,
            y: location.y - size.height / 2
        )
        return CGRect(origin: origin, size: size)
    }
    
    /// サブスクロールビューのframeを計算
    private func calculateSubScrollViewFrame(at page: Int) -> CGRect {
        var frame = mainScrollView.bounds
        frame.origin.x = calculateX(at: page)
        return frame
    }
    
    private func resetZoomScaleOfSubScrollViews(without exclusionSubScrollView: UIScrollView) {
        for subview in mainScrollView.subviews {
            guard
                let subScrollView = subview as? UIScrollView,
                subScrollView != exclusionSubScrollView
                else {
                    continue
            }
            subScrollView.setZoomScale(subScrollView.minimumZoomScale, animated: false)
        }
    }
    
    private func image(at page: Int) -> UIImage? {
        return UIImage(named: "\(page)")
    }
}

extension ViewController: UIScrollViewDelegate {
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        if scrollView != mainScrollView { return }
        
        let page = calculatePage(of: scrollView)
        if page == currentPage { return }
        currentPage = page
        
        pageControl.currentPage = page
        
        // 他のすべてのサブスクロールビューの拡大率をリセット
        resetZoomScaleOfSubScrollViews(without: scrollView)
    }
    
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return scrollView.subviews.first as? UIImageView
    }
    
    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        guard let imageView = scrollView.subviews.first as? UIImageView else { return }
        
        scrollView.contentInset = UIEdgeInsets(
            top: max((scrollView.frame.height - imageView.frame.height) / 2, 0),
            left: max((scrollView.frame.width - imageView.frame.width) / 2, 0),
            bottom: 0,
            right: 0
        )
    }
}

@IBActionで設定してもよいことや、ストーリーボードでも設定できることも書いてありますが、 今回は設定を見やすくするためにソースコードに書き起こしています。

ライフサイクルについて

座標などの計算が絡まないページコントロールの処理はviewDidLoad()に入れますが、 座標計算が絡む処理は、レイアウト制約による各ビューの幅と高さが確定するviewDidLayoutSubviews()に入れておくことにします。

今回のサンプルは縦固定のiPhoneアプリを想定していますが、画面の回転を受け入れるアプリであれば きっとこのままでは良くない結果になるので、もう少し工夫がいるかと思います。

サブスクロールビュー

各画像を拡大縮小させるにはスクロールビューの仕組みを使うことになります。

これはページングで使用するメインのスクロールビューとはまた別で、各ページごとにメインスクロールビューの子として配置されることになります。 メインとなるスクロールビューをmainScrollViewとしたのに対して、 各ページに配置するスクロールビューはsubScrollViewとし、「サブスクロールビュー」と呼ぶことにします。

サブスクロールビューを生成しているのが、generateSubScrollView(at:)になります。 ここでは、サブスクロールビューに対してダブルタップを検出できるようにタップジェスチャレコグナイザを与えておくことにします。

そうして出来上がったサブスクロールビューに対して一杯になるようにイメージビューを配置しています。

この時点で画像がページングできる画面ができあがっているはずです。

ページコントロール

ページコントロールによる操作のハンドルはdidValueChangePageControl()で行われます。 setContentOffset(_:animated:)にてスワイプで行わないページングも実現します。

これと同時にメインスクロールビューのページングされたイベントもページコントロールに反映させなければなりません。 それを行っているのがUIScrollViewDelegateのscrollViewDidEndDecelerating(_:)です。 ページングされたときに何かを更新する必要があるときはここで行うといいと思います。

その他

画像の拡大縮小を行うに際してはscrollViewDidZoom(_:)が呼ばれます。

下記は拡大縮小した画像を画面の真ん中に配置するようにcontentInsetを再計算して調整しています。 ここの調整は仕様によるとは思いますが、ちゃんと制御しておくことで拡大時に画像が意図しない場所を拡大してしまうなどの不具合を抑えられると思います。

contentInsetの値は0を下回らないようにmax()関数で制御しておきます。

scrollView.contentInset = UIEdgeInsets(
    top: max((scrollView.frame.height - imageView.frame.height) / 2, 0),
    left: max((scrollView.frame.width - imageView.frame.width) / 2, 0),
    bottom: 0,
    right: 0
)

最後に

おそらく実務的なアプリでは、画像の枚数が動的に変わったり、 ネットワーク経由で画像を取得する必要があったり、 大量の画像を見る必要があったりと色々な要件をこなさないといけないかもしれません。

その際にはクラス設計や、スレッド・メモリ管理などの工夫が必要となると思いますが、 ベースはこのような作り方になるかなと思います。

この手のやつは色々な組み方があるとは思いますが、何かの参考になればと思います。