[iOS] UICollectionView でセルの領域外にはみ出る要素を配置する

こんな感じに配置する

赤いUIView がセルからはみ出している

前提条件

Xcode: 12.1
シミュレータ: iOS14系

というわけで早速作っていきます。

セルの領域外にはみ出る要素を配置する

まずコレクションビューを適当に作ります。
(雛形は本ブログの [iOS] UIViewController の中に UICollectionView を貼り付ける とかを参考にサクッと)

で、あとは UIView.layer.zPosition を調整し、UIView.layer.masksToBoundsfalse にするとすぐにできます。

zPosition のデフォルト値は0なので、それよりも大きな値を指定し、
他の要素よりもZ軸で上に配置されるようにします。

zPositionの設定ははみ出る要素自体ではなく、それを乗せているセルに対して行います。
はみ出る要素自体に設定してしまうと、スクロールして再描画したときに、
それが他の要素の下側に表示されてしまう場合があります。

masksToBounds をfalseにすると、
セルの領域からはみ出した要素を切り取らずに表示できるようになります。

ということで、はみ出る要素を持つUICollectionViewCell に対して
以下のようなコードを書いてあげれば実現できます。

let shouldOverhanging = true // はみ出る要素を持つUICollectionViewCell ならば

if shouldOverhanging {
    let overhangingView = UIView(frame: CGRect(x: 50, y: -100.0, width: 150, height: 120)) // 上にはみ出すUIView
    contentView.addSubview(overhangingView!)

    layer.masksToBounds = false
    layer.zPosition = 1
}

こうすると、冒頭の動画のように
セルの領域からはみ出したUIView (動画の赤い四角のパーツ)を表示することができます!

prepareForReuse でUIView たちをremove してやるのも忘れずに。

override func prepareForReuse() {
    super.prepareForReuse()
         
    let subviews = contentView.subviews
    for subview in subviews {
        subview.removeFromSuperview()
    }
} 

これでセルの領域外にはみ出る要素を配置できました。

最後に

以上です。お疲れ様でした!

[iOS] hitTest(_:with:) の挙動の概要

hitTest(_:with:) とは

タップした位置からどのUIView がタップされたかを判断するUIViewのメソッドです。
オプショナルなUIView を返却します。

以下Apple の公式ドキュメントより。

Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point.
指定されたポイントを含むビュー階層(それ自体を含む)内のレシーバーの最も遠い子孫を返します。(拙訳)

https://developer.apple.com/documentation/uikit/uiview/1622469-hittest

文章だけだとイメージしづらいと思うので図で示します。

基本的な処理の流れ

図にすると以下のイメージです。
UIView1 -> UIView2 -> UIView3 の順にaddSubView されていて、
オレンジの枠内をタップしたときの図。

hitTest の処理概要

hitTest メソッドは内部で以下のような再帰処理を行っています。

  1. タップ位置はUIView1 の枠内である
  2. タップ位置はUIView1 のsubview であるUIView2 の枠内である
  3. タップ位置はUIView2 のsubview であるUIView3 の枠内である
  4. UIView3 の subview はない

そして返り値として、最後に枠内だと判定したUIView3 を返却します。
結果、iOSはUIView3 がタップされたと判断されます。

基本的な考えはこんな感じです!
イメージしやすかったでしょうか🤔

例はめちゃくちゃシンプルな状況でしたが、
もう少し複雑な場合については Qiita: [iOS] hitTest(:with:)を具体例で解説する が分かりやすいかと思います!
勝手ながら補足すると、こちらで言及されている point(inside:with:) はUIView のメソッドで、
タップされたポイントがUIView の領域内に含まれているかをBool で返却します。

iOSのデフォルトの実装では推測ですが、hitTest 内の処理で
タップ位置が枠内かどうかを判定するときに呼び出していると思われます。

最後に

見た目の重なりがシンプルな親子関係通りであれば分かりやすいと思います。

ですが、例えばCollectionViewCell の領域からUIView がはみ出し、それが他のセルに被さっている場合は
もう少し考えることが増えそうです🤔
そのパターンについては、実践する機会があったので次回以降まとめようと思います。

今回は以上です!お疲れ様でした!

[iOS] UIView の高さをドラッグで変え、指を離したときに中途半端な高さだったら特定の高さに吸着させる

こんな感じ

吸着される

方針

  1. ドラッグしたときにUIViewの高さを変える
  2. 指を離したときに上下どちらかに吸着する

ドラッグしたときにUIViewの高さを変える

まず最初に、ドラッグでUIView の高さを変える処理を書きます。

Storyborad に ViewController と対象のUIView を貼り付けます。
以下のように、対象のUIView の制約は下・右・左・高さの4箇所に付与します。

で、次にコードを書いていきます。
対象のUIView と、その高さの制約、2つをIBOutlet でViewController に接続します。

@IBOutlet weak var expandableView: UIView!
@IBOutlet weak var expandableViewConstraintHeight: 

対象のUIView は expandableView というプロパティ名にしてみました。

次に、expandableView でドラッグを検知するために UIGestureRecognizer を付与します。
viewDidLoad などに書きます。

let panGesture = UIPanGestureRecognizer(
    target: self,
    action: #selector(didPan(_:))
)
expandableView.addGestureRecognizer(panGesture) 

ドラッグを検知したときのデリゲートメソッドとして didPan(_:) というメソッドを呼ぶように書きました。
で、そのdidPan(_:) 内の処理は以下のような感じ。

@objc
func didPan(_ recognizer: UIPanGestureRecognizer) {
    let point: CGPoint = recognizer.translation(in: self.view)
    expandableViewConstraintHeight.constant += -(point.y)
         
    recognizer.setTranslation(CGPoint.zero, in: self.view)
} 

これで、UIView をドラッグすると高さが変わるようになりました。

高さに上限・下限を設ける

このままだとUIView の高さが無限に大きくなったりマイナスになったりするので、制約に上限を設けます。
今回は高さの最大は画面いっぱいになるまで、最小は100 としました。

let height: CGFloat = expandableViewConstraintHeight.constant - point.y
expandableViewConstraintHeight.constant = min(max(height, 100.0), 812.0) 

本来ならマジックナンバーではなく計算して数値を求めるべきですが、
今回の本質ではないので簡単のために数値決め打ちです。

次はいよいよ本題、いい感じに吸着させます。

指を離したときに上下どちらかに吸着する

指を離したことの検知

まず指を離したことを検知します。
UIGestureRecognizer.State というEnum でジェスチャーの状態を判定できるので、
switch 文で処理を分岐すればよいです。

@objc
func didPan(_ recognizer: UIPanGestureRecognizer) {
    let point: CGPoint = recognizer.translation(in: self.view)
    let height: CGFloat = expandableViewConstraintHeight.constant - point.y
         
    switch recognizer.state {
        case .changed:
            expandableViewConstraintHeight.constant = min(max(height, 100.0), 812.0)
        case .ended:
            let height: CGFloat = expandableView.frame.size.height > 406.0 ? 812.0 : 100.0
            adjust(height) // アニメーションで吸着させる(後述)
        case .possible, .began, .cancelled, .failed:
            break
        @unknown default:
            break
    }
    
    recognizer.setTranslation(CGPoint.zero, in: self.view)
} 

.changed が指を動かしているときの状態、.end が指を離したことを示します。

指を離したときの上下どちらに吸着するかは、
適当に最大の高さの半分以下なら下、半分以上なら上、としました。

吸着のアニメーション

で、次に吸着のアニメーションです。

func adjust(_ height: CGFloat) {
    expandableViewConstraintHeight.constant = height
    UIView.animate(
        withDuration: 0.5,
        delay: 0.0,
        usingSpringWithDamping: 0.7,
        initialSpringVelocity: 0.0,
        options: [.curveLinear],
        animations: {
            self.view.layoutIfNeeded()
        }
    )
}

animations 内のクロージャですが、self.expandableView.layoutIfNeeded() だとアニメーションが発動しないので要注意です。

で、こんな動きになりました!

吸着される

今回のコードを全て記述すると

こんな感じです!

class SampleViewController: UIViewController {
    @IBOutlet weak var expandableView: UIView!
    @IBOutlet weak var expandableViewConstraintHeight: NSLayoutConstraint!
    override func viewDidLoad() {
        super.viewDidLoad()
         
        let panGesture = UIPanGestureRecognizer(
            target: self,
            action: #selector(didPan(_:))
        )
        expandableView.addGestureRecognizer(panGesture)
    }
     
    @objc
    func didPan(_ recognizer: UIPanGestureRecognizer) {
        let point: CGPoint = recognizer.translation(in: self.view)
        let height: CGFloat = expandableViewConstraintHeight.constant - point.y
         
        switch recognizer.state {
        case .changed:
            expandableViewConstraintHeight.constant = min(max(height, 100.0), 812.0)
        case .ended:
            let height: CGFloat = expandableView.frame.size.height > 406.0 ? 812.0 : 100.0
            adjust(height)
        case .possible, .began, .cancelled, .failed:
            break
        @unknown default:
            break
        }
         
        recognizer.setTranslation(CGPoint.zero, in: self.view)
    }
     
    func adjust(_ height: CGFloat) {
        expandableViewConstraintHeight.constant = height
        UIView.animate(
            withDuration: 0.5,
            delay: 0.0,
            usingSpringWithDamping: 0.7,
            initialSpringVelocity: 0.0,
            options: [.curveLinear],
            animations: {
                self.view.layoutIfNeeded()
            }
        )
    }
} 

最後に

今回は指を離した位置のみで上下どちらに吸着するかを判定していましたが、
UIPanGestureRecognizervelocity メソッドで指の移動速度も取得できるので、
素早くフリックすると上下に吸着する、みたいなこともできます。

以上です。お疲れ様でした!

[iOS] UICollectionView のスクロールに合わせてヘッダーの高さが変わる没入感のあるUIを作る

こういうUIを作りたい

iPhone11 の場合。

iPhone11 の場合

iPhone8 の場合。

iPhone8 の場合

(iPhone11 の動画が無駄な余白でめっちゃ縦長に見えるんだが。。😇)

スクロールに合わせてヘッダー部分の領域の高さも可変にしたいです!
ということで、それのやり方をまとめました。

まずはCollectionView 部分を作る

これはすぐにできます。

本ブログの [iOS] UIViewController の中に UICollectionView を貼り付ける の記事で書いた方法で、
UIViewController の中に UICollectionView を貼り付ける形で作ります。
UICollectionView は上下左右いっぱいに制約をつけます。

その際、CollectionView の上下の制約の付け方に注意です。
上下の制約を、SafeArea ではなく、親のView(= UIViewController のView) につけます。
図示するとこんな感じ。

上下の制約を親のView につける

また、UIViewController の背景色は黒にしています。

すると、以下動画のようになります。

Xcode11の場合。

iPhone11の場合

iPhone8の場合。

iPhone8 の場合

特に細かい位置調整をしていませんでしたが、iPhone11 の場合とiPhone8 の場合どちらも
一番上の開始位置がステータスバーの直下になるよう自動的に調整されます

これは viewWillAppear と viewDidAppear の間の処理で、
自動でスクロール位置(= collectionView.contentOffset.y) の調整がなされているためで、
iPhone11の場合は-48.0、iPhone8 の場合は-20.0 でした。

また、safeAreaInsetstop は iPhone11の場合は48.0、iPhone8 の場合は20.0 でした。

つまり、ステータスバーに被らないように、その高さ分最初から
CollectionView の位置がマイナスに調整されているという挙動になります。

ヘッダー部分を作る

固定の高さを持つヘッダー部分を作成

以下の画像のように、親のView の上に UIView を貼り付けます。
Z軸でCollectionView より上に持っていきたいので、CollectionView より後に加えます。
背景色は半透明の白です。

ヘッダー部分のUIView を貼り付ける

ここで、ヘッダー部分の高さを、ステータスバーを除いた部分を100にしたいとします。
要は以下の画像の感じ。

画像に書いている通りステータスバーの高さは端末によって異なってきます。
そのため、「ヘッダー部分の高さ = 100 + ステータスバーの高さ」というように動的に計算する必要があります。

という訳で、ステータスバーの高さを取得します。
以下のコードで取得できます。
出し分けロジックがあるとコードがごちゃつくので、メソッドに切り出してみました。

func statusBarHeight() -> CGFloat? {
    if #available(iOS 13.0, *) {
        let window = UIApplication.shared.windows.filter {$0.isKeyWindow}.first
        return window?.windowScene?.statusBarManager?.statusBarFrame.height
    } else {
        return UIApplication.shared.statusBarFrame.height
    }
} 

これでステータスバーの高さを動的に取得できたので、
あとはヘッダービューの高さの制約をIBOutlet で繋ぎ、
constant に値を代入してやればOKです!

IBOutlet で繋ぎ。

@IBOutlet weak var headerViewConstraintHeight: NSLayoutConstraint! 

viewWillAppear もしくは viewDidAppear で高さを設定する。
(VCの表示タイミングによって最適な記述場所は変わってきそう)

if let statusBarHeight = statusBarHeight() {
    headerViewConstraintHeight.constant = statusBarHeight + 100.0
} 

で、以下動画の感じになりました!

iPhone11 の場合。

iPhone 11の場合

iPhone8 の場合。

iPhone8の場合

セルの上部がヘッダー部分に被ってアレなので、セルの表示開始位置を少し下げます。

セルの開始位置を下げる

こちらは ViewDidLoad に記述します。

additionalSafeAreaInsets = UIEdgeInsets(
    top: 100.0,
    left: 0,
    bottom: 0,
    right: 0
) 

こうするとSafeArea が領域が加算されます。
CollectionView は SafeArea の分、既に位置調整がなされているので(iPhone11 なら48.0)、
それに更に100(つまりヘッダー部分からSafeArea の値を引いた高さ)を加算しました。

こんな感じになります!

iPhone11 の場合。

iPhone11 の場合

iPhone8 の場合。

iPhone8 の場合

ヘッダー部分の高さを動的に変える

スクロールに合わせてヘッダー部分の高さを変えます。
以下のような方針でいきます。

  1. CollectionView のスクロールに合わせてヘッダー部分の高さも変える
  2. ヘッダーの高さの最小値は SafeArea の高さ、最大値は SafeArea + 100 の高さ
  3. スクロール領域を超える位置でのスクロールはヘッダーの高さを変えない

1.CollectionView のスクロールに合わせてヘッダー部分の高さも変える

下へのスクロールか上へのスクロールかをしたかを判定したいので、
直前のスクロール位置との比較をすることで実現したいと思います。

という訳でViewController のプロパティに以下を用意します。

private var previousContentOffsetY: CGFloat = 0.0 

で、今回はVC表示時にSafeAreaInsets が自動調整されている & additionalSafeAreaInsets で更にいじっているので、
その調整が終わったあとに初期値を設定します。
ということで viewDidAppear で以下のように設定します。

previousContentOffsetY = -(statusBarHeight() ?? 0.0 + 100.0 )

なぜ viewDidAppear か。
viewWillAppear と viewDidAppear の間にSafeArea を考慮した位置調整ロジックが自動で入り、
そこで一度 scrollViewDidScroll が呼ばれます。
なので、そのロジックが終わって初期表示が終わったタイミングできちんと設定しています。

あとはこの previousContentOffsetY プロパティを使って、
デリゲートメソッドである scrollViewDidScroll(_:) にてヘッダーの高さを調節します。

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let height = headerViewConstraintHeight.constant - (scrollView.contentOffset.y - previousContentOffsetY)
    
    headerViewConstraintHeight.constant = height
    previousContentOffsetY = scrollView.contentOffset.y
} 

これでスクロールに合わせてヘッダーの高さも増減するようになりました。

が、無制限に増減するので、
最小の高さ(SafeArea の高さ)から最大の高さ(SafeArea + 100 の高さ)の範囲になるよう調整します。

2.ヘッダーの高さの最小値は SafeArea の高さ、最大値は SafeArea + 100 の高さ

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let height = headerViewConstraintHeight.constant - (scrollView.contentOffset.y - previousContentOffsetY)
    let minHeight = statusBarHeight() ?? 0.0
    let maxHeight = (statusBarHeight() ?? 0.0) + 100.0
    
    headerViewConstraintHeight.constant = min(max(minHeight, height), maxHeight)
    previousContentOffsetY = scrollView.contentOffset.y
} 

以下動画の動きになりました。
ボヨンボヨンして変ですね!

iPhone11 の場合。

iPhone11 の場合

iPhone8 の場合。

iPhone8 の場合

3.スクロール領域を超える位置でのスクロールはヘッダーの高さを変えない

一番上から更に上にいこうとしたとき。
初期位置のcontentOffset よりも更にマイナス方向にいこうとしているのでこれを防ぎます。

if scrollView.contentOffset.y < -((statusBarHeight() ?? 0.0) + 100.0) {
    return
}

一番下から更に下にいこうとしたとき。
CollectionView の コンテンツ部分(ScrollView) の高さよりも更にプラスにいこうとしているのでこれを防ぎます。

if scrollView.contentOffset.y + collectionView.bounds.height - view.safeAreaInsets.bottom > scrollView.contentSize.height {
    return
} 

こっちの計算式はややこしいので図にすると以下のイメージです。

で、これを動かしてみると以下のようになります。
iPhone8は若干被りが見えてるので、もしかしたら微調整は必要かもですが、だいぶいい感じですね!

iPhone11 の場合。

iPhone11 の場合

iPhone8 の場合。

iPhone8 の場合

これで完成です!

最後に

スクロール位置計算はけっこうややこしくて、ぱっと見何してるか分からなくなりそうなので、
変数名を工夫するなどして可読性を上げた方がよさそうだなと思いました。

以上です。お疲れ様でした!

[iOS] UITabBarController でタブを切り替えても常に見えるView を作る

どういう状態か

UITabBar の上に、半透明の横長View がありますが、これはタブを切り替えても常に表示されています。
こういう見た目を実現したい。

タブを切り替えても常に見えるViewがいる

細かい位置調整などは置いておいて、これを実現するViewController の構成を書いていきます。
ちなみにこちらのStack Overflow を参考にしました。

全体の構成

こんな感じ。

全体の構成

4つのVCを使います。

では作っていきます!

UITabBarController とその子ViewController たちを作る

まずStoryboard 上に UITabBarController を配置します。

その後、2つのViewController を配置します。
今回は本ブログの [iOS] UIViewController の中に UICollectionView を貼り付ける の記事で書いた方法で、
2つのCollectionView を作りました。

次に、UITabBarController にそれぞれのViewController を紐付けます。
Storyboard 上で、controlキーを押しながらUITabBarController をクリックし、
そのまま紐付けたいViewController までドラッグ&ドロップします。

以下のように Segue のメニューが表示されるので、「view controllers」を選択。

2つの子ViewController を紐づけたら、タブ切り替え可能なコレクションビューができました。

常に見えるView を作る

さらに新しくViewController を作ります。
(ここに常に見せたいView を貼り付けていく流れになります。)

で、ViewController を作ったら、まずはContainer View をそこに配置します。
制約はとりあえず上下左右ぴったりにします。

で、その上に「常に見せたいView」を貼り付けます。

こんな感じ。

「常に見せたいView」は、見た目の分かりやすさのため
背景を半透明にしてUITabBar の高さ分下にマージンをとっています。
(紫の四角いView も貼られてますが関係ないので無視してください)

Container View に UITabBarController を貼り付ける

先ほどと同じくSegue で繋げます。

Storyboard 上で、controlキーを押しながらContainer View をクリックし、
そのままUITabBarController までドラッグ&ドロップします。

以下のようにSegue のメニューが表示されるので、 Embeded を選択します。

これで、タブを切り替えても「常に見えるView」ができました!

動かしてみるとこんな感じです。

タブを切り替えても常にView が見える

ただ、CollectionView を一番下までスクロールしたとき、「常に見えるView」が上に被さっていて
最後までちゃんと見ることができていないのが気になります。

これを解消します。

CollectionView の下部にマージンをつける

これは additionalSafeAreaInsets を設定してやれば実現できます。

CollectionView の ViewController にて、viewDidLoad などのメソッドに
以下のようにadditionalSafeAreaInsets を設定します。

additionalSafeAreaInsets = UIEdgeInsets(
    top: 0,
    left: 0,
    bottom: 100,
    right: 0
) 

bottom: 100 を設定したので、この場合は下に100の余白ができるというわけです。

これを動かしてみるとこんな感じになります。

CollectionView の全ての要素がちゃんと見える

これで完成です!

最後に

案外さっくりできますね。
以上です。お疲れ様でした!

[iOS] UIViewController の中に UICollectionView を貼り付ける

前説

UIViewController の中に UIViewController を貼り付けたいことはままあると思います。
めちゃくちゃ初歩的ですが、割と細かいやり方を忘れたりするので、
テンプレートとしてこちらに残しておこうと思います。

本ポストに書いているようにStoryboard を設定し、四分割したコードをコピペすれば動くかと思います。

Stroryboard に貼っていく

UIViewController をドラッグ&ドロップして配置し、
その中に UICollectionView (UICollectionViewController ではない)を貼り付けます。

以下画像のような感じ。
CollectionView の制約は、親であるUIViewController の領域いっぱいいっぱいにしています。

UIViewController の中に UICollectionView を貼り付け

また、UIViewController は SampleViewController というクラス名とするので、
右パネルのCustom Class にそのように設定します。

Custom Class nににCustom Class に SampleViewController と設定

あとはコードを書いていきます。
以下4つに分けて書いていきます。

  1. CollectionView を使うための準備
  2. UICollectionViewDataSource
  3. UICollectionViewDelegate
  4. UICollectionViewDelegateFlowLayout

CollectionView を使うための準備

import UIKit

class SampleViewController: UIViewController {
    @IBOutlet weak var collectionView: UICollectionView
    private static let cellIdentifier = "cellIdentifier"
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        collectionView.dataSource = self
        collectionView.delegate = self
        
        collectionView.register(
            UICollectionViewCell.self,
            forCellWithReuseIdentifier: type(of: self).cellIdentifier
        )   
    }   
} 

@IBOutlet weak var collectionView: UICollectionView は Storyboard の UICollectionView と接続しているので忘れずに。
delegate のセットは見たままです。
register(_:forCellWithReuseIdentifier:) はUICollectionView で利用するセルを登録します。
(今回はサンプルなので、素のUICollectionViewCell を登録しています)

UICollectionViewDataSource

ここでは読んで字の如く、CollectionView のデータソースを定義します。

extension SampleViewController: UICollectionViewDataSource {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    } 
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 10
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(
            withReuseIdentifier: type(of: self).cellIdentifier,
            for: indexPath
        )
        
        cell.backgroundColor = .blue
        return cell
    }
} 

numberOfSections(in:) でセクションの数を、collectionView:numberOfItemsInSection: で各セクション中のアイテムの数を定義します。

collectionView(_:cellForItemAt:) でどのようなセルを返すか定義します。
dequeueReusableCell(withReuseIdentifier:for:)withReuseIdentifier の値には register(_:forCellWithReuseIdentifier:) で指定したReusableIdentifier を設定します。

UICollectionViewDelegate

CollectionView でユーザーからのアクションがあったときなどに呼ばれるデリゲートメソッドを定義します。

extension SampleViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        print("selected \(indexPath.item)")
    }
    
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        print("start scrolling")
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        print("scrolling")
    }
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        print("end scrolling")
    }
} 

collectionView(_:didSelectItemAt:) はセルがタップされるときに呼ばれるメソッド。

scrollViewWillBeginDragging(_:)scrollViewDidScroll(_:)scrollViewDidEndDecelerating(_:) はそれぞれ以下のタイミングで呼ばれます。
CollectionView のスクロールが始まったとき、スクロールしている最中、スクロールの慣性がなくなって止まったとき。

UICollectionViewDelegateFlowLayout

CollectionView のレイアウトを定義します。
ちなみにこのプロトコルは上述の UICollectionViewDelegate を継承しています。

extension SampleViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(
            width: collectionView.bounds.size.width,
            height: 100
        )
    }
} 

ここではセルのサイズとして、横幅いっぱい、高さ100で定義しています。

最後に

冒頭で書いた通り、Storyboard を設定して四分割したコードをコピペすれば動くかと思います!
(IBOutlet の接続は忘れずに)

分割して書き出しただけでも考えも整理された気がしました。
以上です。お疲れ様でした!

[デザイン] Figma でPDF形式のアイコンを作る

Figma とは

Figmaは、ブラウザで実行されるインターフェイスデザインアプリケーションですが、実際にはそれだけではありません。チームベースの共同設計プロジェクトには、おそらくこれが最適なアプリケーションだと言っても過言ではありません。

https://webdesign.tutsplus.com/articles/what-is-figma–cms-32272

無料でワイヤーフレームやUIのプロトタイピングが作れる、ブラウザ上で動作するツールです。

アプリにアイコンを組み込もうと Font Awesome などを覗いてみたのですが、
欲しい感じのものがなかったため、自作することにしました。
Figma はめちゃ使いやすいので、簡単なアイコンであれば慣れたら数分で作れそうです。

今回は以下のサイトを参考に作っていきました。

動画の「次へ」ボタンを作りたい

ということでこんなアイコンを作りました。

成果物

Figma にアカウントを作る

利用する前にまずFigma にサインアップする必要があります。
その際、有料アカウントと無料アカウントを選べます。

個人利用の場合は無料で問題ないと思います。
会社などで複数人で編集など使いたい場合は有料の方がよいかもしれません。

詳しくは以下のサイトなどが参考になりそうです。
chot.design: 1-4. Figmaの料金プランと権限管理の説明

作業用のページ作成する

アカウントが作成できたら、https://www.figma.com/files/drafts に行き、「Drafts」の横の「+」ボタンを押します。

ページを作る

すると、作業用のページが作られます。
初期状態は以下のような何もない画面になります。

初期画面

Frame を作成する

では、まず要素たちを配置するベースとなる領域(= Frame)を作ります。
現実の絵画でいうキャンバスのようなものです。

画面左上の「#」のようなアイコンの右側、小さい下向きの矢印をクリックし、「Frame」を選択します。

Frame を選択

その状態で、画面真ん中の灰色の部分をどこでもいいのでクリックします。
すると、以下のようにFrame が配置されます。

Frame を配置

この上に要素たちを配置していく流れになります。
がその前に、作業しやすいようにFrame に3つ設定をします。

  1. グリッドをつける
  2. 背景色をつける(これは今回作るものが白っぽいため)
  3. Frame のサイズを変更(これは作るものによって異なってきますが)

グリッドをつけると方眼用紙のように細かい網目が表示されるので、目視で要素たちの位置を調整するときに役立ちます。
ということでつけます。
Frame を選択した状態で、画面右の「Layout Grid」の右にある「+」を押します。

Layout Grid を選択

すると、以下のようにFarme に網目が表示されます。

網目が表示される

デフォルトの網目は10px です。
変更もできますが、個人的には10px のままでいいかなと思います。

次にFrame の背景色を変更します。
以下のようにFrame を選択した状態で、画面右にある「Fill」にRGBの数値を入力します。

Fill に色を設定

今回は白っぽいアイコンなので作業時灰色背景の方がみやすいため、aaaaaa と適当に入力しました。
ちなみに最終的にアイコンを書き出すときにはFrame は含めないので、適当でいいです。

最後にFrame のサイズを変更します。
Frame を選択した状態で、画面右にあるW(幅) と H(高さ) を入力すればよいです。
今回は400×400 にしてみました。

Frame のサイズを設定

Frame の淵にマウスポインタを持ってくると矢印に変化し、それをドラッグすることで動的にサイズ変更も可能です。

これで準備が整いました。
いよいよ要素達を配置していきます。

三角と棒を配置する

画面左上の「□」ボタンの右にある、小さい下向き矢印をクリックし、「△ Polygon」を選択します。

「△ Polygon」を選択

その状態でFrame の中で適当にクリックすると、以下のように三角が配置されます。

三角が配置される

まず三角形を回転させます。
以下のように画面右側にある角度の設定を「-90」に設定すると、右向きの三角形になります。

三角を回転させる

あとは適当に三角形のサイズを大きくし背景色を白にします。

要素の淵からドラッグしてもサイズ変更が可能と書きましたが、その際「Shift」キーを押しながらやると、
アスペクト比を保ったまま拡縮するので便利です!

で、以下のようになりました。

サイズや色を調整

次に、右側に棒を配置します!
画面左上の「□」ボタンの右にある、小さい下向き矢印をクリックし、「□ Rectangle」を選択します。

「□ Rectangle」を選択

その状態でFrame の中で適当にドラッグアンドドロップすると、以下のように四角形が配置されます。

棒を配置

あとサイズや位置や色を調整すれば、以下のようになります。
だいぶそれっぽくなりました!

それっぽい見た目に

三角と棒を一つの要素として扱うように変更し、アウトライン化する

このままだと背景が真っ白のところに配置したときに何も見えなくなってしまうので、影をつけます。
と言いたいところですが、その前に三角と棒を一つの要素として扱うように変更します。
それぞれの要素に影をつけると、以下の矢印の右側のように影が被ってしまいます(これはこれでかっこいいですが)。

矢印の右側に影が被っていいrいる

というわけで一つの要素として扱います。

Shift キーを押しつつ三角形と棒をクリックして、複数選択します。
そしたら、その領域内で右クリックをします。
すると以下のようにメニューがたくさん表示されるので、「Flatten」を選択します。

Flatten wをsせせnせんtせんたたく選択Flatten を選択

これで一つの要素として扱えるようになります。

更に、アウトライン化をします。
これをすると、アイコンを拡大したときに思ってたのと違う感じになってしまうようです(上述の参考サイト: ポケサイズムより)
以下のように、要素を右クリックしてメニューの中から「Outline Stroke」を選択します。

Outline Stroke を選択

あとは影をつけて完成です!

影をつける

要素を選択した状態で、以下のように画面右にある「Effects」の「+」を押します。

「Effects」の「+」を押す

これで影がつきました。
が、微妙にしか影がついていないので、つき方を調整します。

以下のように、「Drop Shadow」の左にある太陽みたいなマークをクリックします。

太陽みたいなマークをクリック

設定画面が表示されるので、調整していきます。
それぞれの設定項目はこんな感じ。

  • X: 影の横方向の位置
  • Y: 影の縦方向の位置
  • Blur: 影の範囲
  • Spread: 選択できません
  • 000000: 影の色
  • 25%: 透過度。数値が大きいほど透過しない

調整して以下のようにこうなりました。

調整後

Frame の背景を透明にし、グリッドを非表示にすると以下のようになります。
それっぽい!

背景を非表示に

これで完成!
あとはファイル出力するだけです。

PDF に出力する

要素を選択し、画面右にある「Export」の右の「+」ボタンを押します。

「Export」の右の「+」ボタンを押sすす

すると、以下のような表示になります。
「PNG」と表示されているプルダウンリストから「PDF」を選択後、その下の「Export Vector」を押せば、
PDFファイルとしてダウンロードされます。

PDFfふぁいるをファイルをPDFファイルを出力

無事アイコンPDFができました!

最後に

けっこう長くなってしまいましたが、この程度であれば、
操作を覚えてしまえばものの数分で作れるボリュームだと思います。

このレベルのデザインが無料でできるのは嬉しいですね🙌

以上です。お疲れ様でした!

[iOS] iPhone でアプリを強制的に画面回転をさせる

前提条件

以下の環境で実行しています。

  • Xcode 12.0
  • iPhoneシミュレータ: iOS14系

ちなみにiPad については、iOSのバージョンによっては本ポストの方法を使っても画面回転しません。
iPad はどのような向きでも使えるようにするのが望ましいというアップルの方針があったと思います。
(iPhoneもですが、特にiPad は)方向という考え方ではなく、
画面サイズの変更を扱っていると捉えましょう、ということだと思います。

強制的に画面回転をさせる

例えばiPhone 自体は縦向きのままで、プログラムによってアプリを強制的に横向き表示にしたい場合。

UIDevice.current.setValue(
    UIInterfaceOrientation.landscapeRight.rawValue,
    forKey: "orientation"
) 


上記の処理を走らせると、以下画像のようになります。
landscapeRight と指定しているので、アプリのコンテンツのみが90度右回り(時計回り)します。

注意点として、ViewController の回転制御に関するプロパティも以下のように設定しておく必要があります。
本ブログの [iOS] iPhoneでUINavigationController 配下の特定のViewController だけ画面の自動回転を許可する
の記事も参考になるかもしれません。

 override var shouldAutorotate: Bool {
    return true
}
     
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    return .allButUpsideDown 
} 
  • shouldAutorotate に false を指定していると、アプリを回転させられません。
  • supportedInterfaceOrientations に例えば .portrait を指定していると、
    ViewController は縦方向にしか対応しないため、結局横向きになりません。

強制的に画面回転する前後の要素の位置について

強制回転、iPhone本体ごと回転どちらの場合でも当てはまりますが、
NSLayoutConstraint で要素の位置を指定すると回転前後でその設定は変わりません。
例えば、要素の位置を、左端から50、上から150を指定した場合。

このようになります。
縦向きのステータスバーの領域が気になりますが、これは正確な位置にするなら調整が必要かもしれません🤔

回転の検知と画面サイズなどの扱いについて

画面回転の検知は、ViewController にtraitCollectionDidChange(_:) というデリゲートメソッドにて可能です。
回転を検知したとき(正確にはアプリのサイズ変更を検知したとき)に呼ばれるデリゲートメソッドとなります。

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
         
    print("🐱: isPortrait: \(UIDevice.current.orientation.isPortrait)")
    print("🐱: isLandspace: \(UIDevice.current.orientation.isLandscape)") 
    print("🐱: width: \(UIScreen.main.bounds.size.width)")
    print("🐱: height: \(UIScreen.main.bounds.size.height)")
} 

このデリゲートメソッド内で端末の向き、アプリの横幅、アプリの縦幅を🐱デバッグしてみます。
以下のような結果になります。

けっこう直感的に理解しやすい値になっていると思います。
図の通り、アプリのコンテンツのみ横向きの場合と、端末ごと横向きの場合は同値になります。

最後に

iOSの回転まわりのAPIは以前大幅に変わった歴史もあり、けっこうややこしいイメージでしたが、
書いてみるとそうでもなさそうですね。
よかった🙌

以上です。お疲れ様でした!

[iOS] WKWebView と JavaScript を連携する

前提条件

以下の環境で実行しています。

  • Xcode 12.0
  • iPhoneシミュレータ: iOS14系

Qiita: iOS WKWebView ネイティブとローカルJavascript連携 を参考に、それに更に調査を加えました。

JavaScript から WKWebView への連携

アプリ側のコード

まずWKWebViewConfiguration に WKUserContentController を設定し、それをWKWebView に食わせます。
適当なUIViewController 上のコードです。

let config = WKWebViewConfiguration()     
let userContentController: WKUserContentController = WKUserContentController()
userContentController.add(self, name: "hogehogeCallBack")
config.userContentController = userContentController
         
let webView = WKWebView(
    frame: CGRect(x: 0, y: 0, width: 100, height: 100),
    configuration: config
) 
view.addSubview(webView) 

userContentController.add(self, name: "hogehogeCallBack") で、どこが何のコールバックを受けるのか設定しています。

self の箇所は、どのクラスがコールバックを受け取るかを示します。
この場合は self なので、自分自身のクラスが受け取ります。
hogehogeCallBack は、何のコールバックを受け取るかを示します。
この場合は JavaScript から hogehogeCallBack という名前で受け取ります。

次にコールバックを受け取ったあとの処理を書きます(何となくextension で分けています)。

extension SampleViewController: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if(message.name == "hogehogeCallBack") {
             print("🐱: \(message.body)") 
        }
    }
} 

とりあえずprint するだけですが、これでコールバックを受け取って処理することができます。

JavaScript 側のコード

これは一行です。

webkit.messageHandlers.hogehogeCallBack.postMessage("This is hogehogeCallBack"); 

これで、上記の WKScriptMessageHandler のデリゲートメソッドにて”🐱: This is hogehogeCallBack” という文字列が出力されます。

ちなみに、ここで渡しているのはString型ですが、他の型も渡すことができます。

色々な型の受け渡し

例えばArray型の受け渡し。
JavaScript 側のコード。

var array = [1, 2, "a"];
webkit.messageHandlers.hogehogeCallBack.postMessage(array);

アプリ側のコード。

        if(message.name == "hogehogeCallBack") {
            if let array = message.body as? [Any] {
                print("🐱: \(array)")
            }
        } 

上記の例だとJavaScript からは Int と String ごちゃ混ぜの配列を渡しているため、
受け取りのWKWebView 側では Any 型の配列として扱っています。
Int や String のみの配列の場合は、WKWebView 側でもその通りに扱えます。

次に、例えばDictionary 型の受け渡し。
JavaScript 側のコード。

var dictionary = {
    a: 1,
    b: 2,
    c: "a"
};
webkit.messageHandlers.hogehogeCallBack.postMessage(dictionary);

WKWebView 側のコード。

        if(message.name == "hogehogeCallBack") {
            if let dictionary = message.body as? [String: Any] {
                print("🐱: \(dictionary)")
            }
        } 

また、例は載せませんがJavaScript から小数点の数値が渡された場合も、WKWebView 側ではFloat や Double 型として受け取れます。

なのでプリミティブ型は基本受け取れそうです。
JSONで扱える型は、WKWebView 側で受け取れるということかもしれません🤔

次に、WKWebView から JavaScript への連携です!

WKWebView から JavaScript への連携

JavaScript 側のコード

適当に足し算した結果を返却する関数を定義してみました。

function add(a, b) {
    return a + b;
} 

アプリ側のコード

アプリ側では、以下のように evaluateJavaScript メソッドを呼びます。
completionHandler はJavaScript の返り値があるときに、この中で扱えます。

webView.evaluateJavaScript("add(1, 2);", completionHandler: { (object, error) -> Void in
    print("🐱: \(object)")
}) 

この場合は JavaScript の add 関数に、引数1と2を与えて実行しています。
結果はこのようにprint されます。

🐱: Optional(3)

HTMLのロードが終わってからでないとJavascriptのコードを実行できない点にご注意を (Qiita: iOS WKWebView ネイティブとローカルJavascript連携 より)

最後に

以上です。お疲れ様でした!

[iOS] iPhoneでUINavigationController 配下の特定のViewController だけ画面の自動回転を許可する

前提条件

以下の環境で実行しています。

  • Xcode 12.0
  • iPhoneシミュレータ: iOS14系

ちなみにiPad は本ポストの方法を使っても、自動回転は無効化できません。
(iPad はどのような向きでも使えるようにするのが望ましいというアップルの方針があったと思います。)

まずアプリ全体の設定をする

TARGETS > General > Deployment Info > Device Orientation
で以下のようなチェックがついていることを確認します。
特定のViewController についてではなく、アプリ全体として対応する場合がある全ての向きにチェックを入れます。
デフォルトでこのようになっていると思います。

ViewController に回転制御のロジックを追加

回転させたくないViewController について

以下のように書きます。

override var shouldAutorotate: Bool {
    return false
}

override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    return .portrait
}

それぞれのメソッドは読んで字の如くですが、

  • shouldAutorotate: 画面を自動回転させるか否か
  • supportedInterfaceOrientations: ViewController が対応している画面の方向。この場合は縦方向のみに制限しています。

回転させたいViewController について

以下のように書きます。

override var shouldAutorotate: Bool {
    return true
}

override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    return .allButUpsideDown 
}

.allButUpsideDown は縦(順方向)、横に対応という意味です。
縦(逆方向)のみ対応しないことになります。

UINavigationController にも回転制御のロジックを追加

ViewController のみであれば上記の設定で完了ですが、
そのViewController たちをUINavigationController 配下として表示している場合、ロジックは働きません。

UINavigationController にもロジックを追加してやる必要があります。

open override var shouldAutorotate: Bool {
    guard let viewController = self.visibleViewController else {
        return false
    }
    return viewController.shouldAutorotate
}

open override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    guard let viewController = self.visibleViewController else {
        return .allButUpsideDown
    }
    return viewController.supportedInterfaceOrientations
} 

UINavigationController にも shouldAutorotatesupportedInterfaceOrientations のロジックを追加してやるのですが、
その返り値は、表示しているViewController の設定に従う、というものになります。

これで狙い通りの挙動になると思います!

最後に

以上です。お疲れ様でした!