うるおいらんど
アイキャッチ画像

【Swift4】UICollectionViewを使ってカルーセルを実装してみた。【ページング編】

どうも。Reoです。前回の続きです!

UICollectionViewを使ってカルーセルを実装しています。 第1回:無限スクロール編 第2回:セルの拡大縮小編 第3回:コレ 第4回:セルの装飾編

目標物はコレです。

 

前回の第2回までの実装↓

今回でとりあえず完成形まで作る予定で書いていきます!

完成形まで行かなかったので、今回はページング部分だけをしていきます!

前回までのCarouselViewを用いて実装していきます。

 

初期表示でセルの縮小を反映させよう!

前回までの実装では、画面を開いたときは同じサイズで横並びになっていました。

これを修正するために前回scrollViewDidScroll(_:)内で書いていた処理をlayoutSubviewsに書き直していきます。

class CarouselView: UICollectionView {
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // 画面内に表示されているセルを取得
        let cells = self.visibleCells
        for cell in cells {
            // ここでセルのScaleを変更する
            transformScale(cell: cell)
        }
        
    }

    // 略
    
}
extension CarouselView: UIScrollViewDelegate {
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {

        // 略
        
        // 削除
//        // 画面内に表示されているセルを取得
//        let cells = self.visibleCells
//        for cell in cells {
//            // ここでセルのScaleを変更する
//            transformScale(cell: cell)
//        }
    }
    
}

これで実行すると、初期表示で既にScaleが変更された状態で表示されます。

最初は初期表示の対策に書いていましたが、scrollViewDidScroll(_:)で処理しなくてもこちらだけで上手くいくことがわかったのでscrollViewDidScroll(_:)の方の処理は消しました。

 

初期表示位置を真ん中にしよう!

画面を開いたときにセルが画面端に表示されているので、それを中心に移動させる処理を書きます。

class CarouselView: UICollectionView {
    // 初期位置を真ん中にする
    func scrollToFirstItem() {
        self.layoutIfNeeded()
        if isInfinity {
            self.scrollToItem(at:IndexPath(row: self.pageCount, section: 0) , at: .centeredHorizontally, animated: false)
        }
    }

    //略

}

この関数をViewControllerのViewDidAppear(_:)にて呼び出します。

class ViewController: UIViewController {
    
    //略

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        carouselView.scrollToFirstItem()
    }
}

これで開いたときに最初のセルが真ん中にくるようになりました。

layoutIfNeeded()は『 「setNeedsDisplay」、「setNeedsLayout」、「layoutIfNeeded」、「layoutSubviews」の違い - Qiita』がわかりやすいです。

引用するとlayoutIfNeeded()の役割は以下です。

この処理を呼ばれたView、及びその配下のViewに 画面更新の必要があった場合 にすべて 即座に 配置します。

scrollToItemを呼んでも上手く動かないときはlayoutIfNeededを呼んでみてください。

 

scrollToItemは指定したセルを指定した位置までスクロールするもので、初期表示時にはアニメーションがいらないのでfalseにしています。

ここにIndexPath(row:0,section:0)ではなくpageCountを設定するのは、無限スクロールでは端までいくことがまずないからです。

説明がムズカシイ。

 

ページングっぽくしよう!

今回の本題はこれです。

厳密にはページングではないんですが、スクロールがピタッとセルの真ん中で止まるようにしていきます。

 

UICollectionViewのisPagingEnabledを使う?

UICollectionViewのisPagingEnabledをtrueにするとコレクションビューがページングできるようになります。

しかし、これはページ(画面サイズ)ごとにページングするものなので今回のカルーセルではズレてしまいます。

例えば、セルのサイズが画面幅と同じときに

convenience init(frame: CGRect) {
    let layout = UICollectionViewFlowLayout()
    // frame.width は画面幅と等しいとき
    layout.itemSize = CGSize(width: frame.width, height: frame.height / 2)
    layout.scrollDirection = .horizontal
    layout.minimumLineSpacing = 0
    
    self.init(frame: frame, collectionViewLayout: layout)

    self.isPagingEnabled = true
}

のようにコードを修正してやると以下のようなものができます。

こういう場合はisPagingEnabled=trueにしてやると簡単にできます。

minimumLineSpacingが0以外だとずれるので、0に設定する必要があります。

このカルーセルもイイネ!

 

でも今回はセル単位でページングがしたいのです。

UICollectionViewFlowLayoutのサブクラスを作成する!

セルごとにページングっぽくするために、UICollectionViewFlowLayoutのサブクラスを作成します。

上部ナビゲーションよりFile > New > File … より CocoaTouchClassを選択し、UICollectionViewFlowLayoutのサブクラスのPagingPerCellFlowLayoutを作成します。

 

先にCarouselViewで用いるFlowLayoutをPagingPerCellFlowLayoutに変更しておきます。

convenience init(frame: CGRect) {
    //let layout = UICollectionViewFlowLayout()
    let layout = PagingPerCellFlowLayout()  //置き換える
    
    // 略
}

 

PagingPerCellFlowLayoutのtargetContentOffset(forProposedContentOffset:withScrollingVelocity:)をoverrideしていきます。

class PagingPerCellFlowLayout: UICollectionViewFlowLayout {
    
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint
    {
         //停止したい位置を計算して返す
}

この関数内で停止位置を計算して座標を返すのですが、計算方法は色々です。

 

方法1

自分が最初に実装したコードはこんな感じです。

import UIKit

class PerCellPagingFlowLayout: UICollectionViewFlowLayout {
    
    var cellWidth:CGFloat = 200
    let windowWidth:CGFloat = UIScreen.main.bounds.width
    
    
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        
        var offsetAdjustment:CGFloat = CGFloat(MAXFLOAT)
        let horizontalOffest:CGFloat = proposedContentOffset.x + ( windowWidth - cellWidth ) / 2
        let targetRect = CGRect(x:proposedContentOffset.x,
                                y:0,
                                width:self.collectionView!.bounds.size.width,
                                height:self.collectionView!.bounds.size.height)
        
        let array = super.layoutAttributesForElements(in: targetRect)
        
        for layoutAttributes in array! {
            let itemOffset = layoutAttributes.frame.origin.x
            if abs(itemOffset - horizontalOffest) < abs(offsetAdjustment) {
                offsetAdjustment = itemOffset - horizontalOffest
            }
        }
        
        return CGPoint(x:proposedContentOffset.x + offsetAdjustment, y:proposedContentOffset.y)
    }
}

UICollectionViewでのCellごとのページング - shingt blog」を参考にさせていただき、swift4で置き換えた形になっています。

この実装での挙動は以下のような感じ

 

指を離しても慣性でスクロールはし続けて、止まりそうなときに一番近いセルで止まります。

でもこれ最後の方見てわかる通り、実は確実ではないんですよね。でもするーっとした感じでスクロールします。

 

方法2

次に、stackoverflowより「ios - "targetContentOffset" UICollectionViewFlowLayout not working properly - Stack Overflow」の回答をそのまま。

import UIKit

class PagingPerCellFlowLayout: UICollectionViewFlowLayout {
    
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint
    {
        if let collectionViewBounds = self.collectionView?.bounds
        {
            let halfWidthOfVC = collectionViewBounds.size.width * 0.5
            let proposedContentOffsetCenterX = proposedContentOffset.x + halfWidthOfVC
            if let attributesForVisibleCells = self.layoutAttributesForElements(in: collectionViewBounds)
            {
                var candidateAttribute : UICollectionViewLayoutAttributes?
                for attributes in attributesForVisibleCells
                {
                    let candAttr : UICollectionViewLayoutAttributes? = candidateAttribute
                    if candAttr != nil
                    {
                        let a = attributes.center.x - proposedContentOffsetCenterX
                        let b = candAttr!.center.x - proposedContentOffsetCenterX
                        if fabs(a) < fabs(b)
                        {
                            candidateAttribute = attributes
                        }
                    }
                    else
                    {
                        candidateAttribute = attributes
                        continue
                    }
                }

                if candidateAttribute != nil
                {
                    return CGPoint(x: candidateAttribute!.center.x - halfWidthOfVC, y: proposedContentOffset.y);
                }
            }
        }
        return CGPoint.zero
    }
    
}

この方法だとこんな感じになります。

ちょっとキビキビした感じの動きになります。

 

実機で動かした感じだと、方法1は指を離してしばらく慣性でスクロールし、方法2は指を離してすぐのセルでストップます。

どちらも毎回必ず真ん中で止まるわけじゃないんですよね。

 

方法2は頻度は少ないんですが、時々targetContentOffsetが呼ばれてないような動きをしてます。

方法1はズレる頻度が2より多い印象です。

 

方法3(考え中)

まだちょっと考え途中なんですが、こういうアプローチもありっぽい。

UICollectionViewFlowLayoutでなく、UIScrollViewDelegateプロトコルだけで済ませる方法です。

extension CarouselView: UIScrollViewDelegate {
    
    //速度velocityを持っていてドラッグが終わりそうなとき。最終的な落ち着き先をtargetContentOffsetで指定
    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) {
        
        targetContentOffset.pointee = targetContentOffsetPoint(scrollView, velocity: velocity)
    }
    
    func targetContentOffsetPoint(_ scrollView: UIScrollView,velocity:CGPoint) -> CGPoint{
        let cellWidth:CGFloat = 200
        let windowWidth:CGFloat = UIScreen.main.bounds.width
        var offsetAdjustment:CGFloat = CGFloat(MAXFLOAT)
        let horizontalOffest:CGFloat = scrollView.contentOffset.x + ( windowWidth - cellWidth ) / 2

        let targetRect = CGRect(x:scrollView.contentOffset.x + velocity.x * cellWidth,
                                y:0,
                                width:self.bounds.size.width,
                                height:self.bounds.size.height)
        
        let array = self.collectionViewLayout.layoutAttributesForElements(in: targetRect)
        
        for layoutAttributes in array! {
            let itemOffset = layoutAttributes.frame.origin.x
            if abs(itemOffset - horizontalOffest) < abs(offsetAdjustment) {
                offsetAdjustment = itemOffset - horizontalOffest
            }
        }
        return CGPoint(x:scrollView.contentOffset.x + offsetAdjustment, y:scrollView.contentOffset.y)
    }
    
}

 

targetContentOffsetPointは落ち着き先の座標を返します。この中身は方法1とほとんど同じです。

動きはこんな感じ。

方法1とほとんど同じなんですが、1つ違うところはvelocityを使っているところです。

let targetRect = CGRect(x:scrollView.contentOffset.x + velocity.x * cellWidth,
                        y:0,
                        width:self.bounds.size.width,
                        height:self.bounds.size.height)

 

上手いことvelocityを組み込めれば結構いい動きすると思うんですが、今の所はウーンって感じです。

方法1,2と違ってUICollectionViewFlowLayoutのサブクラスを作らなくていいという点では楽なような楽じゃないようなって感じです。

なんかどれもこれも不完全な動きなんですよねー。

 

ただアプローチの方法は結構色々ある!ということです。

もう少し探ってみてこれらより良い実装方法が見つかったらまた追記します!

 

全体コード

ちょっと予定より長くなってしまったので、今回はこの辺にしておきます。

全体コードには方法1~3をそのままコメントアウトして表示しておきます。

https://gist.github.com/uruly/ddc85f66190abe560692fcabdc52e233

完璧を目指すのは難しい。

 

どの方法が良いかは用途によると思います。

たくさんのカードから1つを選ぶという場合は、慣性スクロールががしっかり効いて、ドゥルルルルルってなる方が良いですし、アイキャッチの画像を1つずつ見せるとき(例えば漫画アプリのトップとか)は1ページずつ動いたほうが良いですよね。

この記事に関しては、また別の方法を追記するかもしれません。別記事にするかここに追記するかはまだ未定。

 

次回、セルを装飾する!

今回で終わらせるつもりが予想外に長くなってしまいました。しかも方法だけの紹介で、中身のコードを全く説明してないという…(コピペが多いから…)

もう少し腰を据えて考えないとダメですわ〜〜〜。

 

今回で動きはできた(ことにした)ので、次回はとりあえず完成形と同じ状態になるような装飾をしていこうと思います。

まぁおまけみたいな記事です。

 

完璧ではないですが何か参考になるものがあれば嬉しいでっす。

ではまた次回! ブログ書くだけで1日が終わっていくぜ…

第1回:無限スクロール編 第2回:セルの拡大縮小編 第3回:コレ 第4回:セルの装飾編  

参考リンク

Comments

コメントはありません。

現在コメントフォームは工事中です。