20200627のiOSに関する記事は7件です。

UITabBarController において選択されたタブに応じた処理をしたい時

先日、 Google Analytics などのイベントトラッキング系の実装をしている時に、
選択されたタブのログを取りたい、ただし現在開いているタブは取らなくていい。」という場面に出会いました。

画面に依らない共通の処理だったので、それぞれの ViewController ではなく UITabBarController に書くことにしました。
簡単な実装かと思いきや、結構奥深い学びがあったので共有します。

  • バッドプラクティスとそれが悪い実装となる理由
  • 最終的な実装例

を紹介します。

2020 年に Swift 始めたばかりの初心者なので、アドバイス・指摘待っています!!!

ざっくりとした結論

始めに、最終的に至った形を示しておきます。
後の理解を深めるためと、時間がない人のためです。

  1. tabBar(_:didSelect:) を使おう
  2. 引数 itemitems 配列でパターンマッチングしよう
  3. item.tag を使った指定はバッドプラクティスになりがちなので、極力避けよう!

これを読んで、「そんなの当たり前じゃん?」ってなった方はもうここから先を読む必要はないです。
逆に「なんでそれがバッドプラクティス?」ってなった方は読んでみて下さい!!

バッドプラクティス: item.tag を使う

ググってみると、ちょこちょこ見かける方法ですが、これは 基本的にバッドプラクティスになりがち です。
具体的には以下のような実装ですね。 (僕も最初こうやってました)

class TabBarController: UITabBarController {
    override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
        switch item.tag {
        case 0:
            // tag = 0 に対する処理
        case 1:
            // tag = 2 に対する処理
            ...
        default:
            break
        }
    }
}

理由はまだしっかり理解できていませんが (誰が教えて…)、

  1. 個別に tag を設定する必要がある
  2. そしてその tag を参照することで依存が生まれる

からかなーと思っています。

個別に tag を設定する必要がある

ということは、別に左から 0, 1, 2, 3, ... と付けなくても良いわけで…
めちゃめちゃ屁理屈な人が 4, 8, 12, ... とか付けてたら泣きますよね笑

このように特定の UIView インスタンスへの依存を生むような実装は避けるべきです。

最終的な実装例

じゃあどうするかというと、メソッドの引数である itemtabBar.items の配列を照らし合わせる形で実装しました。

まず、実装の前提ですが、以下のような Tab というタブの型を定義しています。

class TabBarController: UITabBarController {
    private enum Tab: Int {
        case home
        case history
        case setting
    }
    ...
}

使うメソッドは先と同じく、 tabBar(_:didSelect:) です。
ただし異なる点として、 tabBar.items の配列とパターンマッチングしていきます。

class TabBarController: UITabBarController {
    ...

    // 現在選択されているタブをプロパティとして持っておく
    private var selectedTab: Tab = .home

    override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
        guard let firstIndex = tabBar.items?.firstIndex(of: item),
              let tab = Tab(rawValue: firstIndex)
              else { return }

        // 表示されている画面と同じタブがタップされた場合は何もしない
        if tab == selectedTab { return }

        switch tab {
        case .home:
            // .home 画面に対する処理
        case .history:
            // .history 画面に対する処理
        case .setting:
            // .setting 画面に対する処理
        }

        // 選択されているタブの更新
        selectedTab = tab
    }
}

肝になるのはここですね。

guard let firstIndex = tabBar.items?.firstIndex(of: item),
      let tab = Tab(rawValue: firstIndex)
      else { return }

何をしているかというと、

  1. 引数である item で選択された UITabBarItem を取得して
  2. tabbaritems (UITabBarItem が順番に入った入った箱) の中の何番目かを firstIndex(of:) で調べて
  3. Tab 型に変換する

ということをしています。
最後の型の変換は今回の実装ならではですが、 2 番目までで、選択されたタブの順番が取得できるので、あとはその順番を使って処理を作っていけばいいです。

この実装では UIKitUIView への依存を生んでおらず、 Tab を使って UITabBar をセットアップしていけば後でバグが生まれるということが少ないです。
(仮にバグが生まれても、発見が早い)

まとめ

UITabBarController 初めて触ったのですが、奥深すぎて仲良くなれる気がしません…
(UITabBarControllerUITabBar の違いも分かってない)


追記
本記事公開後、 @lovee さんより、UITabBarControllerUITabBar の違いについてコメント頂いたのでそのまま記載します!!

ちなみに UITabBarUITabBarController の違いはそれぞれの名前とおり、 UITabBar はバー(UIView 継承)で UITabBarController はコントローラー(UIViewController 継承)です


ですが、今回の実装を経験して、「後から保守性が落ちるコード」、「クラッシュ・バグを生みかねないコード」を書かないように意識して実装したいと思いました。
いずれは人のコード読んで「なんか怪しいな」と怪しいコードを 嗅ぎ分けられる ようになりたいです

この記事は自分の備忘録を建前として、 iOS エンジニアの皆様から「もっとこうしたら良いよ!」 「こんな方法もあるよ!」 「その実装だとこんな時危険だよ!!」 という意見を頂戴することを裏の目的としていますので、
アドバイス・指摘バンバンください!

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift] Date生成時にありえない日付のときはnilを返したい

TL;DR

DateFormatterを使い isLenient = false に設定する。

func makeDate(year: Int, month: Int, day: Int) -> Date? {
    let formatter = DateFormatter()
    formatter.calendar = Calendar(identifier: .gregorian)
    formatter.timeZone = TimeZone(secondsFromGMT: 0)
    formatter.isLenient = false
    formatter.dateFormat = "yyyy-MM-dd"
    return formatter.date(from: String(format: "%d-%02d-%02d", year, month, day))
}

let date = makeDate(year: 2021, month: 2, day: 29)
// -> nil

やりたいこと

例えば年と月と日を別々に入力するUIがあってそこからDate型を生成したいとき、ユーザーが2021年2月29日のような存在しない日付を入力したらnilを返したい。

❌ DateComponentsを使う方法

func makeDate(year: Int, month: Int, day: Int) -> Date? {
    var components = DateComponents()
    components.calendar = Calendar(identifier: .gregorian)
    components.timeZone = TimeZone(secondsFromGMT: 0)
    components.year = year
    components.month = month
    components.day = day
    return components.date
}

let date = makeDate(year: 2021, month: 2, day: 29)
// -> 2021年3月1日

自動で日が進んで別の日付を返してしまう。

❌ DateFormatterを使ってisLenientを指定しない方法

func makeDate(year: Int, month: Int, day: Int) -> Date? {
    let formatter = DateFormatter()
    formatter.calendar = Calendar(identifier: .gregorian)
    formatter.timeZone = TimeZone(secondsFromGMT: 0)
    formatter.dateFormat = "yyyy-MM-dd"
    return formatter.date(from: String(format: "%d-%02d-%02d", year, month, day))
}

let date = makeDate(year: 2021, month: 2, day: 29)
// -> 2021年3月1日

自動で日が進んで別の日付を返してしまう。

ただし、32日を指定するとnilが返ってくる。
この挙動はDateComponentsとは異なる。

let date = makeDate(year: 2021, month: 2, day: 32)
// -> nil

環境

Swift 5.1

謝辞

@kishikawakatsumi さんに教えていただきました。ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

NavigationLinkとList

Discordの swift-developers-japanサーバー で興味深い話題があったので、気になって調べてみたものを雑にまとめました :slight_smile: (話題自体は このへん から)

特に明記していない限り、Xcode 11.5とシミュレーター(iOS 13.5、iPhone SE (1st generation))で確認しています。SwiftUIは まだ発展途上なので 進化が早いので、しばらく経つとここに書いたものは全く意味をなさないゴミになってるかもしれませんが、むしろ早くそうなることを望んでいます :sweat_drops:

NavigationLinkで遷移する前の画面はつながっている

まずは NavigationLink を使ったこんな例を見てください。
A, B, Cのボタンが並んだ画面(一覧画面)が表示され、Aを押すと詳細画面へ遷移します。

FirstViewSecondView

全体のコードはこうなっています。

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            FirstView()
        }
        .environmentObject(ItemContainer())
    }
}

struct FirstView: View {
    @EnvironmentObject var container: ItemContainer

    var body: some View {
        VStack(spacing: 16) {
            ForEach(container.items) { item in
                NavigationLink(destination: SecondView(id: item.id)) {
                    Text("\(item.id)")
                }
            }
        }
        .navigationBarTitle("Items")
    }
}

struct SecondView: View {
    @EnvironmentObject var container: ItemContainer
    let id: String

    var body: some View {
        VStack(spacing: 16) {
            Button("rotate") {
                self.container.rotateItem()
            }

            Button("remove first") {
                self.container.removeItem(id: self.container.items.first?.id ?? "")
            }

            Button("remove this") {
                self.container.removeItem(id: self.id)
            }
        }
        .navigationBarTitle(id)
    }
}

struct Item: Identifiable {
    let id: String
}

final class ItemContainer: ObservableObject {
    @Published var items: [Item] = [
        Item(id: "A"),
        Item(id: "B"),
        Item(id: "C"),
    ]

    func rotateItem() {
        var items = self.items
        if let item = items.popLast() {
            items.insert(item, at: 0)
        }
        self.items = items
    }

    func removeItem(id: String) {
        items = items.filter { $0.id != id }
    }
}

さて、Aの詳細画面にて「rotate」をタップすると、 ItemContainerrotateItem() が呼ばれて、最後のアイテムを先頭へ持ってくるように並び替えます。つまり A, B, C だったものが C, A, B になるわけです。

前の画面( FirstView )は ItemContainer を参照しているので、このとき画面には見えていませんが、UIはちゃんと作り直されています。
「rotate」をタップしてちょろっと前の画面へpopしかけるジェスチャーをすればボタンの並びが C, A, B に変わっているのがわかります。もう一度「rotate」をタップすると B, C, A になっています。

rotate

次に「remove first」をタップすると、先頭のアイテムが削除されます。 B, C, A だったものが C, A になります。

remove first

「remove this」をタップすると、自分自身のアイテムを削除します。 A が削除されて C だけが残るはずですが、そうするとどうなるでしょう?

remove this

答えは、自動的に一覧画面へ戻ります。おそらく、Aの詳細画面のViewや、そこへ遷移していた NavigationLink が存在しなくなったからでしょう。
つまり、前の画面のUIが作り変えられることが、自分の画面に影響するということです。
画面遷移単位ではなくて、全部がつながっているんだと考えると、一貫性のある妥当な動作だと言えます。

※なお、Aの詳細画面からさらに NavigationLink を使って第3画面まで進めるようにして、そこで A を削除すると、なぜか画面は戻りません。そこで手動で前の画面へ戻るとそのままAの詳細画面にいる矛盾した状態が作れます(iOS 13.5)。しかし、 Xcode 12 beta + iOS 14 beta では、第3画面で A を削除した時点で一覧画面まで戻るので、2つ画面を進んだ時に矛盾が発生するこの動作はおそらくiOS 13のバグなんじゃないかと思います。

一覧画面を List に変更する

ここで FirstViewbodyVStack + ForEach ではなく、 List に変えてみます。

var body: some View {
    List(container.items) { item in
        NavigationLink(destination: SecondView(id: item.id)) {
            Text("\(item.id)")
        }
    }
    .navigationBarTitle("Items")
}

こちらの方がより一般的でしょう。画面はこうなります。

List

ここから先ほどと同じように、Aの詳細画面へ遷移して、同じように「rotate」をタップしてみます。A, B, C だったものが C, A, B の並び変わるだけのはずですが、やってみると、なぜか一覧画面に戻ってしまいます。

List rotate

これはちょっと困ってしまう挙動です。Aが削除されたわけではないのに勝手に画面が戻ってしまうのでは使い物になりません。

何が起こっているのだろうか(推測)

「rotate」で前の画面へ戻った際に、コンソールになにやらログが出ています。

[TableView] Warning once only: UITableView was told to layout its visible cells and other contents without being in the view hierarchy (the table view or one of its superviews has not been added to a window). This may cause bugs by forcing views inside the table view to load and perform layout without accurate information (e.g. table view bounds, trait collection, layout margins, safe area insets, etc), and will also cause unnecessary performance overhead due to extra layout passes. Make a symbolic breakpoint at UITableViewAlertForLayoutOutsideViewHierarchy to catch this in the debugger and see what caused this to occur, so you can avoid this action altogether if possible, or defer it until the table view has been added to a window. Table view: <_TtC7SwiftUIP33_BFB370BA5F1BADDC9D83021565761A4925UpdateCoalescingTableView: 0x7ffab2044800; baseClass = UITableView; frame = (0 0; 320 568); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x600000a54db0>; layer = <CALayer: 0x6000004becc0>; contentOffset: {0, -111}; contentSize: {320, 132}; adjustedContentInset: {111, 0, 0, 0}; dataSource: <_TtGC7SwiftUIP13$7fff2c9a5ad419ListCoreCoordinatorGVS_20SystemListDataSourceOs5Never_GOS_19SelectionManagerBoxS2___: 0x7ffab1514330>>

UITableView was told to layout its visible cells and other contents without being in the view hierarchy (the table view or one of its superviews has not been added to a window).

どうやら List は内部的には UITableView を使って実現されているようですが、ビュー階層(ここでのビューは UIView )に存在しないときにそれを更新しようとして失敗してるように見えます。
おそらく見えている部分のセルに対応する View が仮想Viewツリー上に構築されるようになっているんじゃないかと思うのですが、そこで失敗していったん List の下のViewツリーがなくなっちゃってるのかもしれませんね。

UITableViewAlertForLayoutOutsideViewHierarchy にシンボリックブレークポイントを張るとデバッガでキャッチできるよ、とあるので張ってみましたが、 -cellForRowAtIndexPath: を呼んでいるなということしかわかりませんでした。

スタックトレース(の一部)
#0  0x00007fff48e6ddd0 in UITableViewAlertForLayoutOutsideViewHierarchy ()
#1  0x00007fff48e6cacc in -[UITableView _updateVisibleCellsNow:] ()
#2  0x00007fff48e81c3b in -[UITableView _cellForRowAtIndexPath:usingPresentationValues:] ()
#3  0x00007fff48e81b10 in -[UITableView cellForRowAtIndexPath:] ()
#4  0x00007fff2c606dc6 in ListCoreCoordinator.updateListContents(_:) ()
#5  0x00007fff2c6062a7 in ListCoreCoordinator.updateUITableView(_:to:) ()
#6  0x00007fff2c604e94 in ListRepresentable.updateUIView(_:context:) ()

まとめ

  • 画面遷移しても、Viewとしては前画面とつながっているし、見えていなくても前画面は更新される。それが現在の画面に影響を与えることもある。(これ自体は矛盾が発生しない一貫性のある妥当な動作だと思います)
    • が、iOS 13.5時点ではさらに遷移して3画面になるとなんか一貫性が崩れる → iOS 14 betaでは直っているのでこれはバグか?
  • List から NavigationLink で画面遷移したあと、前画面の List で内容の順番に変更が入るだけで前画面に戻される。困る :fearful:
  • 内部的に UITableView を使ってるところでうまくいってないのか?(推測)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSのExposureNotificationのAPIをサンプルアプリとドキュメントから見てみる

ExposureNotification概要

ExposureNotificationはCOVID-19への潜在的な曝露を人々に通知します。
ExposureNotificationの機能を実現するにはExposure Notification Serverの実装が必須です。

おことわり

実機ビルドするには Exposure Notification Entitlement Requestで申請する必要があり、ハードルが高いので、
あくまで、公式ドキュメントとサンプルアプリのシミュレータでの確認になっています。
大いに間違っている可能性があるので公式ドキュメントを正としてください。

公式ドキュメント

https://developer.apple.com/documentation/exposurenotification

サンプルアプリ

https://developer.apple.com/documentation/exposurenotification/building_an_app_to_notify_users_of_covid-19_exposure

ENManager

activate

func activate(completionHandler: @escaping ENErrorHandler)

ENManagerを使う前に最初に呼び出す。
これを行い、完了ハンドラが戻ってきたところでENManagerの機能が使えるようになる。

setExposureNotificationEnabled

func setExposureNotificationEnabled(_ enabled: Bool,completionHandler: @escaping ENErrorHandler)

曝露通知を有効/無効にするクラス。
自動的に通知許可をもらうダイアログも表示する。
許可が断られた場合、completionHandlerはENError.notAuthorizedを返す?
enabledをfalseにした場合、 Bluetoothのスキャンとアドバタイズを停止するが、取得していた診断データはそのまま残る。
残念ながらシミュレータで動作させてもDomain=NSOSStatusErrorDomain Code=-71148 のErrorになります。

detectExposures

func detectExposures(configuration: ENExposureConfiguration, 
    diagnosisKeyURLs: [URL], 
   completionHandler: @escaping ENDetectExposuresHandler) -> Progress

曝露を検出する。
diagnosisKeyURLsは、Exposure Notification ServerからダウンロードしたキーのローカルURLとする。
configurationに関しても、Exposure Notification Serverから取得するデータから生成することをサンプルアプリでは想定している。

このAPIはBluetooth経由で得ているデータをワンショット返すようで、サンプルアプリではBackgroundTasksフレームワークを用いて定期的に検出を行っている。

completionHandlerで返ってくる ENDetectExposuresHandler には ENExposureDetectionSummary が含まれていて、後述の getExposureInfo で使用する。

getExposureInfo

func getExposureInfo(summary: ENExposureDetectionSummary, 
     userExplanation: String, 
   completionHandler: @escaping ENGetExposureInfoHandler) -> Progress

前述の detectExposures から得た ENExposureDetectionSummary をパラメータにして曝露情報を取得する。
userExplanation はUIの一部としてユーザーに表示するために使われる。
コールバックとして ENExposureInfo が受け取れる。
サンプルアプリではこの情報を独自の構造にマッピングしてローカルに保存している。

getDiagnosisKeys

func getDiagnosisKeys(completionHandler: @escaping ENGetDiagnosisKeysHandler)

ENTemporaryExposureKey を取得する。
このメソッドを呼び出すたびに、ユーザーは承認を行う必要がある。
このキーはそのままExposure Notification Serverに送信する必要があり、サンプルアプリでは一旦JSONにデコードして送信する想定となっている。

getTestDiagnosisKeys

func getTestDiagnosisKeys(completionHandler: @escaping ENGetDiagnosisKeysHandler)

getDiagnosisKeys のテスト用API。
シミュレータで動作させてもDomain=NSOSStatusErrorDomain Code=-71148 のErrorになります。

invalidate

func invalidate()

ENManagerを無効にする。

ENStatus

曝露通知システムの有効状態を示すenum。
特筆したいのがbluetoothOffという種別であり、ENStatusにしかBluetooth使用を示すものがないので、ExposureNotificationのフレームワーク内で完全にBluetoothの処理は閉じていてBluetoothに対するコントロールは出来なさそう。

tvOS14(おまけ)

tvOS14にもExposureNotification.frameworkが有効なんですが、

  • ENAuthorizationStatus
  • ENStatus
  • ENError

と、限定的なAPIしか使えず、メインのENManagerが使えない模様。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

@IBDesignable を指定した View の中で Asset Catalog の内容を読み取るために必要なこと

概要

@IBDesignable を指定したカスタム View クラスの中で UIColor クラスのイニシャライザ init(named:) を呼び出す処理を書いておき、Interface Builder 内で、ある View Controller クラスの View にそのカスタム View クラスを 追加したところ、以下のようなビルドエラーが発生しました。

IB Designables: Failed to render and update auto layout status for [View Controller のクラス名] ([Interface Builder 内での View Controller の Object ID]): The agent crashed

この問題の原因について調査したところ、 @IBDesignable を指定したカスタム View クラスの prepareForInterfaceBuilder() から呼び出される処理の中で Asset Catalog の内容を正しく読み出されないためであることがわかりました。

prepareForInterfaceBuilder() から呼び出される処理の中でも Asset Catalog の内容を正しく読み取れるように設定することにより、 Asset Catalog 内で定義した色情報を使用できるようになりました。

エラーログの格納場所

本記事が対象とするエラーの詳細ログは ~/Library/Logs/DiagnosticReports ディレクトリ内の IBDesignablesAgent-iOS_YYYY-MM-DD-HHMMDD_[マシン名].crash に記録されています。

@IBDesignable を指定したカスタム View の定義方法

@IBDesignable を指定したカスタム View クラスを使用することにより、 Interface Builder 上でカスタム View のプレビューをするなどの利点があります。

@IBDesinable を使用する利点や使用方法については、下記リンク先の記事が参考になります。

カスタムコンポーネントを使用したUI実装について - ZOZO Technologies TECH BLOG

prepareForInterfaceBuilder() から Asset Catalog の内容を読み取る

prepareForInterfaceBuilder() から呼び出される処理の中で UIColor クラスのイニシャライザ init(named:) を呼び出すとビルドエラーとなるのは、 init(named:) では Asset Catalog の内容を正しく読み取ることができないためです。

Asset Catalog の内容を正しく読み取るためには init(named:) の代わりに init(named:in:compatibleWith:) を使って、 対象の Bundle を明示的に指定する必要があります。具体的なコードの例を下に示します。

ビルドエラーとなる例
@IBDesignable final class RoundedRectButton: UIButton {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupAttributes()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupAttributes()
    }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        setupAttributes()
    }

    private func setupAttributes() {
        clipsToBounds = true
        layer.cornerRadius = 8.0
        layer.borderWidth = 2.0
        layer.borderColor = UIColor.black.cgColor
        // この実装だとビルドエラー
        backgroundColor = UIColor(named: "MyColor")!
    }
}
ビルド成功となる例
@IBDesignable final class RoundedRectButton: UIButton {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupAttributes()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupAttributes()
    }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        setupAttributes()
    }

    private func setupAttributes() {
        clipsToBounds = true
        layer.cornerRadius = 8.0
        layer.borderWidth = 2.0
        layer.borderColor = UIColor.black.cgColor
        // この実装だとビルド成功
        backgroundColor = UIColor(named: "MyColor", in: Bundle(for: type(of: self)), compatibleWith: nil)!
    }
}

prepareForInterfaceBuilder() から呼び出される処理の中では、 Asset Catalog 内の画像を使用する場合にも同様に、 UIImage クラスのイニシャライザには init(named:) ではなく init(named:in:with:) を使う必要があります。

その他の対応方法

今回着目したビルドエラーは上記対応方法にて解決できるのですが、もしその他の理由により prepareForInterfaceBuilder() 内で実行する処理とそれ以外を区別したい場合には、 TARGET_INTERFACE_BUILDER というプリプロセッサマクロが役に立ちます。

TARGET_INTERFACE_BUILDERの使用例
#if TARGET_INTERFACE_BUILDER
    // prepareForInterfaceBuilder()内でのみ有効となる
#else
    // prepareForInterfaceBuilder()内以外でのみ有効となる
#endif

参考情報

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

IBDesignable を指定した View の中で Asset Catalog の内容を読み取るために必要なこと

概要

@IBDesignable を指定したカスタム View クラスの中で UIColor クラスのイニシャライザ init?(named:) を呼び出す処理を書いておき、Interface Builder 内で、ある View Controller クラスの View にそのカスタム View クラスを 追加したところ、以下のようなビルドエラーが発生しました。

IB Designables: Failed to render and update auto layout status for [View Controller のクラス名] ([Interface Builder 内での View Controller の Object ID]): The agent crashed

この問題の原因について調査したところ、 @IBDesignable を指定したカスタム View クラスの prepareForInterfaceBuilder() から呼び出される処理の中で Asset Catalog の内容を正しく読み出されないためであることがわかりました。

prepareForInterfaceBuilder() から呼び出される処理の中でも Asset Catalog の内容を正しく読み取れるように設定することにより、 Asset Catalog 内で定義した色情報を正常に使用できるようになりました。

エラーログの格納場所

本記事が対象とするエラーの詳細ログは ~/Library/Logs/DiagnosticReports ディレクトリ内の IBDesignablesAgent-iOS_YYYY-MM-DD-HHMMDD_[マシン名].crash に記録されています。

@IBDesignable を指定したカスタム View の定義方法

@IBDesignable を指定したカスタム View クラスを使用することにより、 Interface Builder 上でカスタム View のプレビューをするなどの利点があります。

@IBDesinable を使用する具体的な利点や使用方法については、下記リンク先の記事が参考になります。

カスタムコンポーネントを使用したUI実装について - ZOZO Technologies TECH BLOG

prepareForInterfaceBuilder() から Asset Catalog の内容を読み取る

prepareForInterfaceBuilder() から呼び出される処理の中で UIColor クラスのイニシャライザ init?(named:) を呼び出すとビルドエラーとなるのは、 init?(named:) では Asset Catalog の内容を正しく読み取ることができないためです。

Asset Catalog の内容を正しく読み取るためには init(named:) の代わりに init(named:in:compatibleWith:) を使って、 対象の Bundle を明示的に指定する必要があります。具体的なコードの例を下に示します。

ビルドエラーとなる例
@IBDesignable final class RoundedRectButton: UIButton {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupAttributes()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupAttributes()
    }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        setupAttributes()
    }

    private func setupAttributes() {
        clipsToBounds = true
        layer.cornerRadius = 8.0
        layer.borderWidth = 2.0
        layer.borderColor = UIColor.black.cgColor
        // この実装だとビルドエラー
        backgroundColor = UIColor(named: "MyColor")!
    }
}
ビルド成功となる例
@IBDesignable final class RoundedRectButton: UIButton {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupAttributes()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupAttributes()
    }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        setupAttributes()
    }

    private func setupAttributes() {
        clipsToBounds = true
        layer.cornerRadius = 8.0
        layer.borderWidth = 2.0
        layer.borderColor = UIColor.black.cgColor
        // この実装だとビルド成功
        backgroundColor = UIColor.init(named: "MyColor", in: Bundle(for: type(of: self)), compatibleWith: nil)
    }
}

prepareForInterfaceBuilder() から呼び出される処理の中では、 Asset Catalog 内の画像を使用する場合にも同様に、 UIImage クラスのイニシャライザには init?(named:) ではなく init?(named:in:with:) を使う必要があります。

その他の対応方法

今回着目したビルドエラーは上記対応方法にて解決できるのですが、もしその他の理由により prepareForInterfaceBuilder() 内で実行する処理とそれ以外を区別したい場合には、 TARGET_INTERFACE_BUILDER というプリプロセッサマクロが役に立ちます。

TARGET_INTERFACE_BUILDERの使用例
#if TARGET_INTERFACE_BUILDER
    // prepareForInterfaceBuilder()内でのみ有効となる
#else
    // prepareForInterfaceBuilder()内以外でのみ有効となる
#endif

参考情報

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS 開発初心者による Xcode におけるビルドの使い分け

iOS 開発者の皆さん、 ビルド 、してますか!? (当たり前)
ビルド待ちって筋トレしたくなるくらい暇な時間ですよね。

ビルド待ちの間についつい Twitter や Slack 見てて、気づいたら夜だったなんてこともあったりなかったり。

僕は iOS 開発を初めて 2 ヶ月の初心者ですが、そんな僕から「ビルドの使い分け」をシェアしたいです。
最初は ⌘ + R しか知らなかった僕ですが、今ではその他に 2 つのビルド方法 を使い分けています。

本題

今日紹介するビルド方法は以下の 3 つです。

  • ⌘ + R (Run)
  • ⌘ + ⇧ + R (Build for Running)
  • ⌘ + ⌃ + R (Run without Building)

この 3 つがあれば、以下のような使い分けができます。

  • Run: ビルドして、実機 or シミュレータで動作を確認したいとき
  • Build for Running: ビルドだけして、動作確認はしないとき
  • Run without Building: ビルドはせずに、実機 or シミュレータによる動作の確認だけしたいとき

これで UseCase に応じてキーボードショートカットを覚えて帰っていただいても大いに結構なのですが、せっかくなのでそれぞれがどんなことをしているかを軽く説明しときます。

⌘ + ⇧ + R (Build for Running)

順番が最初と違いますが、まずはこれから。
ここでは ビルドのみ 行います。

ビルドとは、コンパイルしたりレジスタを初期化したりゴニョゴニョやる 下準備 みたいなものです。
これに関してもっと理解したい方は この記事 が参考になります。
ざっくり、コードを変更したら必要になるのがビルドです。

そして、 "Build Succeeded" となって成功する or "Build Faild" となると失敗して終了です。

どんな時に使うかと言うと、

  • コードを変更してとりあえずコンパイルだけしたいとき (エラーが出ないかだけ確認したい)
  • 見た目に関する変更が無くて実機 or シミュレータによる動作確認が不要なとき

に使っています。
次、 ⌘ + ⌃ + R (Run without Building) を説明します。

⌘ + ⌃ + R (Run without Building)

これは知らなかった人は是非知ってください。
なんと ビルド待ち時間が 0 になります。やばいですよね。

このコマンドでは、 Run without Building から分かるように、 ビルドをしません
なのでビルド待ちが 0 になるわけです。

じゃあ何をするかというと、 前回成功したビルド結果を元に実行 します。
ここでの実行とは、実機やシミュレータで動作確認することです。
前回成功したビルドなので、どれだけコードを変更しても反映されません
ここだけは注意して使い分けて下さい。

このコマンドは必ず成功して実行されますが、それはあなたが追加・変更したコードがうまく行っていることとは無関係ということです。

どんな時に使うかと言うと、

  • 実行したがクラッシュしてもう一度実行したいとき
  • とりあえず実機による動作確認がしたいとき

などです。「前回のビルド結果でいいから実行したいとき」ですね。

⌘ + R (Run)

最後、一番知られてるやつです。
Xcode 画面左上の ▶ 押して実行するやつです。

このコマンドでは、 ビルド + 実行 をしています。
今回紹介した中では最も時間がかかる処理です。
ビルド + 実行なので、先に紹介した 2 つのコマンドを直列実行しているということですね。

このコマンドは、

  • コードを変更し、その変更を動作確認したいとき

などに使います。

おまけ

ここでは、 Run に関するコマンドでしたが、RU に変えるとユニットテストに全く同じ感覚で使えます。
さらに RI に変えると Profiling とかいう謎のことにも使えるそうです。僕は使ったことないです。

まとめ

まとめると以下のようになります。

ショートカット コマンド名 動作 使用例
⌘ + ⇧ + R Build for Running ビルドのみ とりあえずコンパイルだけしたいとき
⌘ + ⌃ + R Run without Building 実行のみ とりあえず実機による動作確認がしたいとき
⌘ + R Run ビルド + 実行 コードを変更し、その変更を動作確認したいとき

使い分けてビルド待ちを最小にしましょう!!
くれぐれもビルド待ち中に筋トレしすぎてムキムキにならないように!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む