Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

UICollectionViewでサムネイルスライダーを実装する

こんにちは、iOSエンジニアの西村です。

iPhoneの写真アプリには目的の写真を見つけやすくするため、画面下部にサムネイル形式のスライダーが実装されています。最近撮った写真を見返すときに便利なので意外と嬉しい機能の一つです。

今回は、このようなサムネイルスライダーをUIKitで実装する方法をご紹介します。
※ 画面下部には写真のサムネイルが横並びに一覧表示され、現在表示されている画像が中央に配置されています

実装する機能

実装における要件は以下の通りです。

  • サムネイルは画面中央に表示される
  • スクロールしてもサムネイルが中途半端な位置で止まらず、画面中央でぴったり停止する
  • 画面中央のサムネイルが選択されていることがわかるように、画面中央のサムネイルのみ拡大表示する
  • サムネイルのサイズがバラバラでも問題ないようにする

実装した動きはこちらになります。

実装

実装は下記の4ステップで進めていきます。

  1. 横並びのリストを作成する
  2. 画面中央からサムネイルを表示する
  3. スクロールしてもサムネイルが画面中央でピッタリ止まるようにする
  4. サムネイルが画面中央に近づくにつれ拡大し、離れるにつれ縮小する
ソースコードをGitHubに上げているので合わせてご覧ください。 github.com

1. 横並びのリストを作成する

UICollectionViewを使用して横並びのリストを実装していきます。

下記のコードは基本的な横並びのリストを作成しているだけなので詳細は省略しますが、UICollectionViewの高さを120に設定し、セルのサイズをその0.8倍で表示するようにしています。

これはステップ4で行うセルの縮小拡大で必要になってくる設定です。通常表示されるセルのサイズは0.8倍ですが、画面中央に近づくにつれ1倍のサイズに拡大するように設定していく予定です。

class ThumbnailSliderView: UIView, UICollectionViewDelegateFlowLayout {
    private var collectionView: UICollectionView!
    private let collectionViewLayout = UICollectionViewFlowLayout()

    // UICollectionViewの高さを120で設定
    private let viewHeight: CGFloat = 120.0
    // セルのサイズを高さの0.8倍になるように設定
    private let cellScale: CGFloat = 0.8
    private var thumbnails = [UIImage]()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        thumbnails = [
            UIImage(named: "Image 1")!,
            UIImage(named: "Image 2")!,
            // ...
        ]
        
        // 横並びにする
        collectionViewLayout.scrollDirection = .horizontal

        collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
        self.addSubview(collectionView)
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.register(ThumbnailCell.self, forCellWithReuseIdentifier: "cell")
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            collectionView.heightAnchor.constraint(equalToConstant: viewHeight),
            collectionView.centerYAnchor.constraint(equalTo: self.centerYAnchor),
            collectionView.leftAnchor.constraint(equalTo: self.leftAnchor),
            collectionView.rightAnchor.constraint(equalTo: self.rightAnchor)
        ])
    }
}

// MARK: - UICollectionViewDataSource
extension ThumbnailSliderView: UICollectionViewDataSource {

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! ThumbnailCell
        cell.thumbnailImage.image = thumbnails[indexPath.row]
        return cell
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return thumbnails.count
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let cellHeight = collectionView.frame.height * cellScale
        let pageSize = thumbnails[indexPath.row].size
        let scale = cellHeight / max(pageSize.height, 1)
        return CGSize(width: pageSize.width * scale, height: cellHeight)
    }
}

// MARK: - ThumbnailCell
class ThumbnailCell: UICollectionViewCell {
    let thumbnailImage = UIImageView(image: nil)

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(thumbnailImage)
        thumbnailImage.frame = contentView.bounds
        thumbnailImage.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    }

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

2. 画面中央からサムネイルを表示する

画面を開いた際に1枚目のサムネイルが画面中央から表示されるようにします。また端までスクロールしてもサムネイルが画面中央で止まるようにします。

実装はシンプルで、UICollectionViewcontentInsetの左右に対して「画面半分の幅 - サムネイル半分の幅」を余白として指定します。これにより、サムネイルを画面中央から表示させることができます。
※ init内に処理を記述しているため、UICollectionViewのレイアウト情報は利用できず細かいサイズ調整が必要になります。

class ThumbnailSliderView: UIView, UICollectionViewDelegateFlowLayout {

    override init(frame: CGRect) {
        // 省略...

        // 画面中央にサムネイルが表示されるようにする(レイアウト完了前に調整する必要があるためセルのサイズ計算をする)
        let cellHeight = viewHeight * cellScale

        let firstImageSize = thumbnails.first?.size ?? .zero
        let firstCellScale = cellHeight / firstImageSize.height
        let firstCellSize = CGSize(width: firstImageSize.width * firstCellScale, height: cellHeight)
        let leftMargin = (frame.width - firstCellSize.width) / 2

        let lastImageSize = thumbnails.last?.size ?? .zero
        let lastCellScale = cellHeight / lastImageSize.height
        let lastCellSize = CGSize(width: lastImageSize.width * lastCellScale, height: cellHeight)
        let rightMargin = (frame.width - lastCellSize.width) / 2

        collectionView.contentInset = UIEdgeInsets(top: 0, left: leftMargin, bottom: 0, right: rightMargin)
    }
}

3. スクロールしてもサムネイルが画面中央でピッタリ止まるようにする

スクロールするとサムネイルが中途半端な位置で止まってしまうので、サムネイルが画面中央にピッタリ止まるようにスクロールの停止位置を調整していきます。

これ以降の実装では、UICollectionViewのレイアウトを調整するUICollectionViewFlowLayoutを継承したクラスを作成して調整していきます。

まず、スクロールの停止位置を自由に調整できるtargetContentOffset(forProposedContentOffset:withScrollingVelocity:) メソッドがあるので、こちらをオーバーライドしてスクロールの停止位置を調整していきます。このメソッドは、スクロールを開始して指が離れたタイミングで呼ばれます。

次に、メソッドの引数として渡されるproposedContentOffsetパラメーターを使用して、スクロールが停止する予測位置を取得することができます。この停止位置を基に、停止後に画面中央に最も近いサムネイルとの距離を計算し、そのずれを調整してスクロールの停止位置を修正します。

class ThumbnailCollectionViewFlowLayout: UICollectionViewFlowLayout {
 
    /// スクロールを停止するポイントを取得します。
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        // スクロール完了地点に表示される、セルのレイアウト属性を取得
        guard let collectionView,
              let layoutAttributes = super.layoutAttributesForElements(in: CGRect(origin: proposedContentOffset, size: collectionView.frame.size))
        else { return proposedContentOffset }

        // 「スクロール完了地点 + 画面幅の半分」でスクロール完了時の画面中央のポイントを計算
        let horizontalCenter = proposedContentOffset.x + (collectionView.frame.width / 2)

        // 画面中央に最も近いセルが、中央(2の数値)からどれだけ離れているかを保持する変数
        var closestOffset = CGFloat.greatestFiniteMagnitude

        // レイアウト属性を一つずつ確認
        for attribute in layoutAttributes {
            // 各セルの中央と画面中央の距離を計算
            let distanceFromCenter = attribute.center.x - horizontalCenter
            // 画面中央に最も近いセルとの距離を更新
            if abs(distanceFromCenter) < abs(closestOffset) {
                closestOffset = distanceFromCenter
            }
        }

        // スクロール停止地点のx座標を「スクロール停止地点 + 3の変数」にすることで画面中央に近いセルを中央に表示させる
        return CGPoint(x: proposedContentOffset.x + closestOffset, y: proposedContentOffset.y)
    }
}

最後に UICollectionViewFlowLayout を継承したクラスを使うように変更します。

class ThumbnailSliderView: UIView, UICollectionViewDelegateFlowLayout {
    //private let collectionViewLayout = UICollectionViewFlowLayout()
    private let collectionViewLayout = ThumbnailCollectionViewFlowLayout()
}

4. サムネイルが画面中央に近づくにつれ拡大し、離れるにつれ縮小する

画面中央にサムネイルが近づくにつれてサイズが大きくなり、離れるにつれて小さくなるようにしていきます。

まず、サムネイルのレイアウトを変更できるようにlayoutAttributesForElements(in:)メソッドをオーバーライドします。これは、画面に表示されているレイアウト属性をスクロールに合わせて取得できるメソッドです。

これによって画面中央付近にあるサムネイルのレイアウト属性を取得し、拡大縮小の変更を入れていきます。また、拡大時に左右のサムネイルと表示が重ならないように間隔も調整していきます。

class ThumbnailCollectionViewFlowLayout: UICollectionViewFlowLayout {
 
    /// 指定された矩形内のすべてのセルとビューのレイアウト属性を取得する。
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let collectionView, let rectAttributes = super.layoutAttributesForElements(in: rect) else { return nil }
        let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.frame.size)
        var cellWidthDifference = 0.0

        // セルが画面中央に近づくにつれ拡大表示させる
        for attribute in rectAttributes where attribute.frame.intersects(visibleRect) {
            // 画面中央からどれだけセルが離れているかを計算
            let distanceFromCenter = visibleRect.midX - attribute.center.x
            // 拡大縮小を始める範囲を決定
            let maxDistance = attribute.frame.width / 2

            // セルが拡大縮小の範囲内に入っているかを判定
            if abs(distanceFromCenter) < maxDistance {
                // 拡大縮小の倍率を計算
                let originCellWidth = attribute.frame.width                                 // 元々のセル幅
                let cellHeightRatio = collectionView.frame.height / attribute.frame.height  // CollectionViewに対するセルの高さ比率を計算
                let distanceRatio   = distanceFromCenter / maxDistance                      // 画面中央からセルまでの距離比率を計算
                let zoomScale       = 1 + (cellHeightRatio - 1) * (1 - abs(distanceRatio))  // ズーム倍率を計算
                // 拡大縮小を適用
                attribute.transform3D = CATransform3DMakeScale(zoomScale, zoomScale, 1)

                // 次のセル間隔調整のためにセル幅の変化量を保持
                cellWidthDifference = attribute.frame.width - originCellWidth
            }
        }

        // セルの拡大に合わせて、左右のセル間隔を調整する
        for attribute in rectAttributes where attribute.frame.intersects(visibleRect) {
            // 画面中央からどれだけセルが離れているかを計算
            let distanceFromCenter = visibleRect.midX - attribute.center.x
            // 拡大縮小を始める範囲を決定
            let maxDistance = attribute.frame.width / 2

            // セルが拡大縮小の範囲外か判定
            if abs(distanceFromCenter) > maxDistance {
                // セルが左右のどちらにあるかを判定
                if distanceFromCenter > 0 {
                    // 左の場合、拡大縮小したセルの幅分だけセルを左にずらす
                    attribute.frame.origin.x -= (cellWidthDifference / 2)
                } else {
                    // 右の場合、拡大縮小したセルの幅分だけセルを右にずらす
                    attribute.frame.origin.x += (cellWidthDifference / 2)
                }
            }
        }

        return rectAttributes
    }
    
    // boundsが変更されたらレイアウトを更新するかの設定
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        // セルの拡大縮小などのレイアウト変更が更新されるようにtrueを返します
        return true
    }
}
class ThumbnailSliderView: UIView, UICollectionViewDelegateFlowLayout {
    override func layoutSubviews() {
        super.layoutSubviews()
        // 初期表示の調整に必要
        collectionViewLayout.invalidateLayout()
    }
}

最後に

UICollectionViewFlowLayoutを活用して、UICollectionViewを自由にカスタマイズする方法をご紹介しました。

最初はSwiftUIでの実装を試みていたのですが、「サムネイルが画面中央に近づくにつれ拡大し、離れるにつれ縮小する」という要件を満たそうとした際に、パフォーマンスが急激に低下したため泣く泣くUIKitでの実装に切り替えたという背景がありました。

ただ、iOS 17 から利用できる scrollTransition を使えばSwiftUIでシンプルな実装ができそうなので、将来的には再度SwiftUIでの実装に挑戦してみようと考えています。

ヤプリでは一緒に働く仲間を募集しています!「興味をもった!」という方や、「もう少し具体的な話が聞いてみたい」と思った方はぜひカジュアル面談にお越しください。

open.talentio.com