20200906のSwiftに関する記事は17件です。

ファイルを保存するだけで動的にプログラムを更新できるInjectionIIIについて、macOSでもできるようにしてみた

以前、ファイルを保存するだけで動的にプログラムを更新できるInjectionIIIについての解説記事を投稿した。
その際は、対象がiOSだけだった。macOSでもできると書いてあったが、方法がわからず、ようやく少し進展した(まだ道半ば)。
https://qiita.com/KoichiroEto/items/5cb149a6e5d74bbdd66c

3. macOSのプログラムからインジェクションしてみる

これまでは、iOS用アプリをインジェクションしていた。iOSアプリはシミュレーター上で動作しており、macOSアプリはそうではないという違いがある。そのため、いくつか追加の手順が必要となる。

3.1. なにかアプリを作る

まず、さきほどと同様に、なにかシンプルなアプリを開発する。
Xcodeを起動→Create a new Xcode Project→macOS→「App」→Next→Product Name:「MacTest」、User Interface: Storyboard→Next→「~/dev」を指定→Create
ViewController.swiftに、以下のようにshow()を追加。viewDidLoad()から呼ばれるようにする。

ViewController.swift
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        show()
    }
    func show() {
        let label = NSTextField(frame: NSRect(x: Int.random(in: 10..<100), y: Int.random(in: 10..<100), width: 150, height: 50))
        label.backgroundColor = NSColor.cyan
        label.stringValue = "Hello, world!"
        view.addSubview(label)
    }
}

(TextFieldの位置がランダムなのは、諸事情がある。後述する。)
まずはこの段階で実行してみる。Cmd-R→ビルドされ、実行される。ウィンドウが表示され、「Hello, world!」が表示される。
ViewController.swiftに戻り、"Hello, world!""Hello, Japan!"に修正してCmd-Sで保存する。当然、何も反映されない。この時点ではまだインジェクションされていないからだ。
再度Cmd-Rすると、一旦アプリが終了し、再度立ち上げられ、"Hello, Japan!"が表示される。約3秒で立ち上がる。この速度ならあまり不満は持たれないかもしれない。
該当個所を、「Hello, world!」に戻しておく。インジェクションの設定を始めてみよう。

3.2. プロジェクトを設定する

Xcodeに戻る。
Cmd-1→MacTestのプロジェクトを選択→PROJECT: MacTest→Build Settings→Linking→Other Linker Flags→ここにカーソルを乗せると左に三角が表示されるので、それをクリックする→Debugの右の「+」をおす→Any Architecture | Any SDK:「-Xlinker -interposable」→リターンを押すと確定する
Cmd-1→MacTestのプロジェクトを選択→TARGETS: MacTest→Signing & Capabilities→All→App Sandoboxの右の小さな「×」を押して、消す
Cmd-1→MacTestのプロジェクトを選択→TARGETS: MacTest→Signing & Capabilities→All→Hardened Runtime→「Disable Library Validation」をcheck

3.3. Bundleを追加

AppDelegate.swiftにBundleを追加する。

AppDelegate.swift
        #if DEBUG
    Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/macOSInjection.bundle")?.load()
        #endif

参考までに、AppDelegate.swiftの該当するメソッド全体を示す。

AppDelegate.swift
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        #if DEBUG
        Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/macOSInjection.bundle")?.load()
        #endif
    }

3.4. injected()を追加

ViewController.swiftに、injectedというメソッドを追加する。

ViewController.swift
    @objc func injected() {
        show()
    }

参考までに、ViewController classの全体である。

ViewController.swift
import Cocoa
class ViewController: NSViewController {
    @objc func injected() {
        show()
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        show()
        NotificationCenter.default.addObserver(self, selector: Selector("injected"), name: NSNotification.Name(rawValue: "INJECTION_BUNDLE_NOTIFICATION"), object: nil)
    }
    func show() {
        let label = NSTextField(frame: NSRect(x: Int.random(in: 10..<100), y: Int.random(in: 10..<100), width: 150, height: 50))
        label.stringValue = "Hello, world!"
        label.backgroundColor = NSColor.cyan
        view.addSubview(label)
    }
    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }
}

3.5. InjectionIIIにProjectを指定する

InjectionIIIが起動されていなかったら、起動する。
Status menuのInjectionIIIから「Open project」を選択→「~/dev/MacTest」を選択→「Select Project Directory」

3.6. 起動する

Cmd-R→アプリが起動して、「Hello, world!」が表示される。
この状態で、Hello, world!を編集してみる。Cmd-Sで保存する。そうすると、即座にコンパイルされ、読み込まれ、classがreplaceされる。
また、injectedが呼ばれ、そこからshowが呼ばれる。
ただ、前のオブジェクトが残ってしまっている。そのため、以前のTextFieldは消去されない。そのまま残るだけである。
そのため、以前のversionでは場所が固定されているので、文字が変更されない。これが更新される方法は、これから調べる予定。

とりあえず、今日はここまで!

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

App Store Connect でアプロードした時 Too many symbol files のかべにぶち当たったときの対処方法

  • dSYM ファイルがいっぱいあることが原因っぽい (この辺は曖昧)
    • 調べると dSYM ファイルをどうにかするっていう対処法ばっかりだったからそう思ってる

調べた感じ2つ

1つ目

プロジェクトファイルの debug information format を DWARF にする
image.png

dSYMファイルを生成しないという設定らしい (多分)
でも Firebase Crashlytics で dSYM ファイルを使用してどうのこうのするっぽいから生成しないのはちょっとなと思う

2つ目

Podfile に config.build_settings['VALID_ARCHS'] = 'arm64' を追加する

これで余分な architecture のdSYMファイルを生成しないでよいっぽい

アーカイブしたファイル(.xcarchive)のパッケージの内容を表示して、dSYMsのフォルダ以下で dwarfdump --uuid * を実行すると下記のように、ライブラリごとに対応しているarchitecture 一覧が見れる。

下記はオプションを入れる前の状態

dSYMs % dwarfdump --uuid *
UUID: 081C9609-5C30-3CB0-84B7-DF3326EF8146 (arm64) Kingfisher.framework.dSYM/Contents/Resources/DWARF/Kingfisher
UUID: 24163A65-4498-37DB-BEBF-F6227DBFF77C (arm64) Realm.framework.dSYM/Contents/Resources/DWARF/Realm
UUID: 4165D0CD-F99B-36EB-BC2F-8661D5056F33 (arm64) RealmSwift.framework.dSYM/Contents/Resources/DWARF/RealmSwift
UUID: 70DDE517-8A61-3CE3-B1F4-E4B23FBBAD38 (armv7) Rswift.framework.dSYM/Contents/Resources/DWARF/Rswift
UUID: B1138461-D58C-34A9-805C-CAB35DFC9141 (arm64) Rswift.framework.dSYM/Contents/Resources/DWARF/Rswift
UUID: 8A08736F-4BF9-3F50-8A33-B1F2A4A44AAA (arm64) RxCocoa.framework.dSYM/Contents/Resources/DWARF/RxCocoa
UUID: 0EBDB9F9-30C9-3E3C-9C84-B8FFAFF152FE (arm64) RxRelay.framework.dSYM/Contents/Resources/DWARF/RxRelay
UUID: 8EC6ED8C-9989-32F8-88EF-8D4FED75868B (arm64) RxSwift.framework.dSYM/Contents/Resources/DWARF/RxSwift
UUID: 5D9C7297-AE8C-362F-AB92-72926B9243A2 (armv7) SwiftyBeaver.framework.dSYM/Contents/Resources/DWARF/SwiftyBeaver
UUID: DA55A53C-0AB8-35D6-B00B-3BF11D0A060A (arm64) SwiftyBeaver.framework.dSYM/Contents/Resources/DWARF/SwiftyBeaver

下記がオプションを入れた状態

UUID: 081C9609-5C30-3CB0-84B7-DF3326EF8146 (arm64) Kingfisher.framework.dSYM/Contents/Resources/DWARF/Kingfisher
UUID: 24163A65-4498-37DB-BEBF-F6227DBFF77C (arm64) Realm.framework.dSYM/Contents/Resources/DWARF/Realm
UUID: 4165D0CD-F99B-36EB-BC2F-8661D5056F33 (arm64) RealmSwift.framework.dSYM/Contents/Resources/DWARF/RealmSwift
UUID: B1138461-D58C-34A9-805C-CAB35DFC9141 (arm64) Rswift.framework.dSYM/Contents/Resources/DWARF/Rswift
UUID: 8A08736F-4BF9-3F50-8A33-B1F2A4A44AAA (arm64) RxCocoa.framework.dSYM/Contents/Resources/DWARF/RxCocoa
UUID: 0EBDB9F9-30C9-3E3C-9C84-B8FFAFF152FE (arm64) RxRelay.framework.dSYM/Contents/Resources/DWARF/RxRelay
UUID: 8EC6ED8C-9989-32F8-88EF-8D4FED75868B (arm64) RxSwift.framework.dSYM/Contents/Resources/DWARF/RxSwift
UUID: DA55A53C-0AB8-35D6-B00B-3BF11D0A060A (arm64) SwiftyBeaver.framework.dSYM/Contents/Resources/DWARF/SwiftyBeaver

amrv7(iPhone5, iPhone5c以下の端末)はメインプロジェクトで使用しないので、不要となる。

これでアップロードすればきっとだいじょうぶなはず。

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

[SwiftUI]NavigationViewのnavigationBarTitle位置に画像とテキストを両方入れる方法

実装するもの

SwiftUIにおけるSwiftUI NavigationViewでListからNavigationLinkで遷移する方法ではnavigationBarTitleの使い方についても触れましたが、ここでは以下の実装をする為に必要な方法をご紹介したいと思います。

スクリーンショット 2020-09-06 22.08.11.jpg

Code

ContentView
struct ContentView: View {

    var body: some View {

            VStack {
                TitleView(image: Image("SwiftUI"),titleName: "SwiftUI")
                    .frame(width: UIScreen.main.bounds.width * 0.95, height: UIScreen.main.bounds.height * 0.1)                  
                List(1..<100) { num in
                    NavigationLink(destination: Text("Lesson\(num)")) {
                    Text("Lesson\(num)")
                    }
                }      
        }
    }
}

これに加えて
Cmd+Nで新規TitleViewを作成し
スクリーンショット 2020-09-06 22.10.39.jpg

スクリーンショット 2020-09-06 22.10.59.jpg
以下のCodeを追加します。

Code

TitleView
struct TitleView: View {
    let image: Image
    let titleName: String

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                image
                    .resizable()
                    .frame(width: 50, height: 50)

        }
            ZStack {
                Text("\(titleName)")
                    .fontWeight(.black)
                    .foregroundColor(Color.black)
                    .font(.largeTitle)
            }
            Spacer()
        }
        .padding()
        .background(Color.white)
        .clipShape(RoundedRectangle(cornerRadius: 10))
    }
}

解説

現段階でnavigationBarTitleにImageとTextを入れる方法がない(と思う)為、TitleViewを作り、最上部にVstackで配置する事でそれらしく見せる事ができます。また.frame(width: UIScreen.main.bounds.width * 0.95, height: UIScreen.main.bounds.height * 0.1)では高さや幅をデバイスのサイズによって変更する為、綺麗な設計をする上では非常に有効ですので是非ご活用ください。
また使用するImageはassetにあらかじめご用意ください。

スクリーンショット 2020-09-06 22.14.48.jpg

最後に

普段は個人でSwiftUIでアプリを作っています。ハードも作るの大好きです。
よければ下記もチェックして下さい♪
Twitter
https://twitter.com/oka_yuuji
note
https://note.com/oka_yuuji

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

[Swift]Eurekaライブラリのカスタマイズ集

はじめに

Eurekaライブラリとは

Eurekaライブラリとは、iOSの入力フォームなどを、高速かつ簡単に作成するためのライブラリである。
https://github.com/xmartlabs/Eureka

背景

このライブラリの基本的な使い方は様々な記事で紹介されている。しかし、応用的な使い方や、マイナーなカスタマイズは中々載っていなかったため、私自身カスタマイズに苦労した。
私この経験を記事にすることで、皆様のお役に立てるのではないかと思い投稿させて頂いた。

この記事の主なターゲット

  • Eurekaライブラリの基本的な使用方法を知っている方
  • Swift初心者の方
  • Eurekaを使用して間も無く、もっと活用できるようになりたいと感じている方

編集履歴

  • 2020年9月6日(日):本記事を投稿

カスタマイズ集

LabelRowにDisclosure Indicatorを表示

EurekaSample.swift
class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(LabelRow() {
                $0.cell.accessoryType = .disclosureIndicator
            })
        })
    }

}

LabelRowにSub Titleを追加

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(LabelRow() {
                $0.title = "title"
                $0.cellStyle = UITableViewCell.CellStyle.subtitle
            }.cellUpdate { cell, _ in
                cell.detailTextLabel?.text = "sub title"
                cell.detailTextLabel?.textColor = UIColor.systemGray
            }.onCellSelection { cell, row in
                self.navigationController?.pushViewController(UIViewController(), animated: true)
            })
        })
    }

}

PushRowの選択先のVCのSectionを複数にする

無理やりです。

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController {

    private let dataList = [String]()

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(PushRow<String>("tag") {
                $0.title = "title"

                // オプションで追加はしない
                //$0.options
            }.onPresent { from, to in

                // デフォルトのセクションタイトルを消す
                to.form.allSections.first?.header = nil

                // セクション追加
                let section1 = Section("section1")
                section1.tag = "section1"
                let section2 = Section("section2")
                section2.tag = "section2"
                for data: String in self.dataList {
                        section1.append(LabelRow() {
                            $0.title = data
                        }.onCellSelection { cell, row in

                            // デフォルトのPushRowを同じ挙動をさせる
                            self.navigationController?.popViewController(animated: true)
                            (self.form.rowBy(tag: "tag") as! PushRow<String>).value = data
                        })
                }
                to.form.append(section1)
                to.form.append(section2)
            })
        })
    }

}

文字入力系RowのUIToolbarをカスタマイズ

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController, UITextFieldDelegate {

    private var activeTextField: UITextField? = nil

    // TextFieldが選択された時
    func textFieldDidBeginEditing(_ textField: UITextField) {

        // textFieldの参照先をメンバ変数で保持しておく
        self.activeTextField = textField
    }

    // Keyboardの上のUIToolbarの完了ボタン押下時の処理
    @objc private func focusDelete() {

        // 参照先を保持しているTextFieldのFocusを外す
        activeTextField?.resignFirstResponder()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(TextRow() {
                $0.title = "title"
            }.cellUpdate { cell, row in

                // ここでこのRowのTextFieldのDelegateをセットさせる
                cell.textField.delegate = self
            })
        })
    }

    // カスタマイズUIToolbar(右側にDone buttonのみを配置)
    override func inputAccessoryView(for row: BaseRow) -> UIView? {
        let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: self.view.bounds.width, height: 0))
        toolbar.sizeToFit()
        var items = [UIBarButtonItem]()
        items.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil))

        // DoneのActionを設定
        let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(focusDelete))
        items.append(doneButton)
        toolbar.items = items
        return toolbar
    }

}

SectionのHeader、Footerをカスタマイズ

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let section = Section()
        section.footer = { var footer = HeaderFooterView<UIView>(.callback({
            let view = UIView(frame: CGRect(x: 10, y: 0, width: self.view.bounds.width - 20, height: 100))
            return view
        }))
            footer.height = { 180 }
            footer.onSetupView = { view, _ in
                view.preservesSuperviewLayoutMargins = false
                let label = UILabel(frame: CGRect(x: 10, y: 0, width: self.view.bounds.width - 20, height: 100))
                label.font = UIFont.systemFont(ofSize: 13)
                label.numberOfLines = 0
                label.textAlignment = .left
                label.text = "footer description"
                label.sizeToFit()
                view.addSubview(label)
            }
          return footer
        }()
        form.append(section)
    }

}

曜日のPickerInlineRow

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(PickerInlineRow<Weekday>() {
                $0.options = [.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday]
                $0.displayValueFor = {
                    guard let weekday = $0 else { return nil }
                    return weekday.rawValue + "曜日"
                }
            })
        })
    }

}

enum Weekday: String {
    case sunday = "日"
    case monday = "月"
    case tuesday = "火"
    case wednesday = "水"
    case thursday = "木"
    case friday  = "金"
    case saturday = "土"
}

月と日のみのPickerInlineRow

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(DoublePickerInlineRow<String, String>() {
                $0.firstOptions = { return (1...12).map { String($0) + "月" } }
                $0.secondOptions = { month in
                    var days = [String]()
                    switch month {
                    case "4月", "6月", "9月", "11月":
                        days = (1...30).map { String($0) + "日" }
                    case "2月":
                        days = (1...29).map { String($0) + "日" }
                    default:
                        days = (1...31).map { String($0) + "日" }
                    }
                    return days
                }
                $0.displayValueFor = {
                    guard let monthDay = $0 else { return nil }
                    return monthDay.a + monthDay.b
                }
            })
        })
    }

}

おわりに

以上になります。万が一ミスなどをお気づきになりましたら、お手数をおかけしてしまい申し訳ありませんが、コメントなどでお知らせして頂けると助かります。

今後も他のカスタマイズが気付き次第更新したいと思います。

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

[Swift]Eurekaライブラリの応用カスタマイズ集

はじめに

Eurekaライブラリとは

Eurekaライブラリとは、iOSの入力フォームなどを、高速かつ簡単に作成するためのライブラリである。
https://github.com/xmartlabs/Eureka

背景

このライブラリの基本的な使い方は様々な記事で紹介されている。しかし、応用的な使い方や、マイナーなカスタマイズは中々載っていなかったため、私自身カスタマイズに苦労した。
私この経験を記事にすることで、皆様のお役に立てるのではないかと思い投稿させて頂いた。

この記事の主なターゲット

  • Eurekaライブラリの基本的な使用方法を知っている方
  • Swift初心者の方
  • Eurekaを使用して間も無く、もっと活用できるようになりたいと感じている方

編集履歴

  • 2020年9月6日(日):本記事を投稿

カスタマイズ集

LabelRowにDisclosure Indicatorを表示

EurekaSample.swift
class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(LabelRow() {
                $0.cell.accessoryType = .disclosureIndicator
            })
        })
    }

}

LabelRowにSub Titleを追加

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(LabelRow() {
                $0.title = "title"
                $0.cellStyle = UITableViewCell.CellStyle.subtitle
            }.cellUpdate { cell, _ in
                cell.detailTextLabel?.text = "sub title"
                cell.detailTextLabel?.textColor = UIColor.systemGray
            }.onCellSelection { cell, row in
                self.navigationController?.pushViewController(UIViewController(), animated: true)
            })
        })
    }

}

PushRowの選択先のVCのSectionを複数にする

無理やりです。

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController {

    private let dataList = [String]()

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(PushRow<String>("tag") {
                $0.title = "title"

                // オプションで追加はしない
                //$0.options
            }.onPresent { from, to in

                // デフォルトのセクションタイトルを消す
                to.form.allSections.first?.header = nil

                // セクション追加
                let section1 = Section("section1")
                section1.tag = "section1"
                let section2 = Section("section2")
                section2.tag = "section2"
                for data: String in self.dataList {
                        section1.append(LabelRow() {
                            $0.title = data
                        }.onCellSelection { cell, row in

                            // デフォルトのPushRowを同じ挙動をさせる
                            self.navigationController?.popViewController(animated: true)
                            (self.form.rowBy(tag: "tag") as! PushRow<String>).value = data
                        })
                }
                to.form.append(section1)
                to.form.append(section2)
            })
        })
    }

}

文字入力系RowのUIToolbarをカスタマイズ

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController, UITextFieldDelegate {

    private var activeTextField: UITextField? = nil

    // TextFieldが選択された時
    func textFieldDidBeginEditing(_ textField: UITextField) {

        // textFieldの参照先をメンバ変数で保持しておく
        self.activeTextField = textField
    }

    // Keyboardの上のUIToolbarの完了ボタン押下時の処理
    @objc private func focusDelete() {

        // 参照先を保持しているTextFieldのFocusを外す
        activeTextField?.resignFirstResponder()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(TextRow() {
                $0.title = "title"
            }.cellUpdate { cell, row in

                // ここでこのRowのTextFieldのDelegateをセットさせる
                cell.textField.delegate = self
            })
        })
    }

    // カスタマイズUIToolbar(右側にDone buttonのみを配置)
    override func inputAccessoryView(for row: BaseRow) -> UIView? {
        let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: self.view.bounds.width, height: 0))
        toolbar.sizeToFit()
        var items = [UIBarButtonItem]()
        items.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil))

        // DoneのActionを設定
        let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(focusDelete))
        items.append(doneButton)
        toolbar.items = items
        return toolbar
    }

}

SectionのHeader、Footerをカスタマイズ

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let section = Section()
        section.footer = { var footer = HeaderFooterView<UIView>(.callback({
            let view = UIView(frame: CGRect(x: 10, y: 0, width: self.view.bounds.width - 20, height: 100))
            return view
        }))
            footer.height = { 180 }
            footer.onSetupView = { view, _ in
                view.preservesSuperviewLayoutMargins = false
                let label = UILabel(frame: CGRect(x: 10, y: 0, width: self.view.bounds.width - 20, height: 100))
                label.font = UIFont.systemFont(ofSize: 13)
                label.numberOfLines = 0
                label.textAlignment = .left
                label.text = "footer description"
                label.sizeToFit()
                view.addSubview(label)
            }
          return footer
        }()
        form.append(section)
    }

}

曜日のPickerInlineRow

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(PickerInlineRow<Weekday>() {
                $0.options = [.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday]
                $0.displayValueFor = {
                    guard let weekday = $0 else { return nil }
                    return weekday.rawValue + "曜日"
                }
            })
        })
    }

}

enum Weekday: String {
    case sunday = "日"
    case monday = "月"
    case tuesday = "火"
    case wednesday = "水"
    case thursday = "木"
    case friday  = "金"
    case saturday = "土"
}

月と日のみのPickerInlineRow

EurekaSample.swift
import Eureka

class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(DoublePickerInlineRow<String, String>() {
                $0.firstOptions = { return (1...12).map { String($0) + "月" } }
                $0.secondOptions = { month in
                    var days = [String]()
                    switch month {
                    case "4月", "6月", "9月", "11月":
                        days = (1...30).map { String($0) + "日" }
                    case "2月":
                        days = (1...29).map { String($0) + "日" }
                    default:
                        days = (1...31).map { String($0) + "日" }
                    }
                    return days
                }
                $0.displayValueFor = {
                    guard let monthDay = $0 else { return nil }
                    return monthDay.a + monthDay.b
                }
            })
        })
    }

}

おわりに

以上になります。万が一ミスなどをお気づきになりましたら、お手数をおかけしてしまい申し訳ありませんが、コメントなどでお知らせして頂けると助かります。

今後も他のカスタマイズが気付き次第更新したいと思います。

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

[SwiftUI]NavigationViewでListからNavigationLinkで遷移する方法

今回の記事で実装できるもの

SwiftUIでNavigationViewを使えば本当に簡単に以下の様なものが作れます。

スクリーンショット 2020-09-05 22.03.56.jpg

以下ではこの実装の解説をしていきます。

Code

struct ContentView: View {
    var body: some View {
        NavigationView {
            List(1..<100) { num in
                NavigationLink(destination: Text("Lesson\(num)")) {
                Text("Lesson\(num)")
                }
            }.navigationBarTitle("SwiftuUI")
        }
    }
}

これだけ少ないコードで上記の様な
・タイトル
・リスト
・遷移
を実装する事ができます。
ではどの様に実装しているか少し解説していきたいと思います。

解説

NavigationView

・NavigationLink
・navigationBarTitle
はもちろんですが、その他にも
・navigationBarItems
など使用するには必須。

List

(1..<100)でリストの個数を定義しています。今回は1〜99のリストを作成して数字はnumに返しているのでnumもそのままList内で使う事がきますのでText内などでも(\num)で使用する事ができます。

NavigationLink

destination以降でリンク先の表示するものを定義する事ができます。今回はテキストですがimageはもちろん、Viewを指定して画面を遷移させる事も可能です。

navigationBarTitle

("")内に書いたテキストをタイトル位置(画面の上部)に表示させる事ができます。
大きさは
("SwiftuUI", displayMode: .large)
displayModeの後に
large
inline
automatic
で指定する事ができます。

以上です。是非参考になれば嬉しいです。

最後に

普段は個人でSwiftUIでアプリを作っています。ハードも作るの大好きです。
よければ下記もチェックして下さい♪
Twitter
https://twitter.com/oka_yuuji
note
https://note.com/oka_yuuji

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

NSScrollViewのセンタリング(fullSizeContentView, magnification対応付き)

NSScrollView の定番の質問に「コンテンツのセンタリングをしたい」というのがあります。

歴史のあるクラスだけあって Stackoverflow なんかでもちょいちょい転がっている頻出パターンなのですが、fullSizeContentView や magnification まで考慮した現代的な回答が見つからなかったためまとめます。

解法

結論だけ先にいうと、大枠では他所の回答と同じく NSClipView のカスタマイズによって解決します。このビューをデフォルトの NSClipView の代わりに使うことで自動的に中身のコンテンツがセンタリングされます。
(サンプル: https://github.com/ryutei/CenteringContentViewSample)

CenteringClipView.swift
import Cocoa

class CenteringClipView: NSClipView {

    override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
        let base = super.constrainBoundsRect(proposedBounds)

        guard let documentFrame = self.documentView?.frame
        else {
            return base
        }

        let frame = self.frame
        let mag = frame.width / proposedBounds.width // #1
        let insets = self.enclosingScrollView!.contentInsets // #2
          // #3
        let deltaX = max(frame.width/mag-documentFrame.width, 0.0)
        let deltaY = max(frame.height/mag-insets.top/mag-documentFrame.height, 0.0)

        var ret = base
          // #4
        ret.origin.x -= deltaX/2.0 
        ret.origin.y -= deltaY/2.0

        return ret
    }
}

解説

まず NSClipView.constrainBoundsRect(_:) は何をするメソッドなのかというと、基本的にはスクロール(や拡大縮小)の変化に対応して「これ以上行くな」という範囲の制約をかけるためのものです。

例えばマウスのホイールを回すとその変化量に応じてビューがスクロールビューの中を移動するわけですが、何も制限がかかっていなければコンテンツはやがて画面から外れて無限遠に流れていくことになります。そこで、フレームやらコンテンツの大きさやらを見て、もう端まで来ていると判断したら境界値でストップさせるよう値を調整するわけです。

引数に渡される proposedRect は取り敢えず何も考えずに変化量を反映させてみた場合の値であり、コンテンツが実際に適切な位置にあるかどうかは考慮されていません。なのでこちらがすることは proposedRect の値を参考にしつつ、都合のいい rect に変形して返してやる作業になります。

で、これで具体的に何を計算するのかというと、NSClipView の bounds を返します。NSScrollView はユーザー定義の documentView に対して、一種の選択領域となる contentView (NSClipView) を動かすことで documentView のどの範囲を表示するか制御しています。bounds が移動するとその分 documentView はオフセットされ、sizeを変えると documentView の倍率が変わることになります。なので、ビューが空いている時のセンタリングをしたければ bounds の origin を余白のサイズだけ移動してやればいいことになります。

NSClipView は NSScrollView によって実際に表示される範囲に合わせて大きさが設定されるので、余白の量は基本的には自身の frame と documentView の frame の差ということになります。これが負の値になったら余白がないということなので、この場合は 0.0 にするのが普通です(#3)

ちなみに、bounds に対するジオメトリの変化は documentView に対して逆向きに作用するという点に注意してください。上でも説明したとおり、NSClipView は documentView の選択領域を定義するという考え方なので、bounds が正の方向に移動するということはドキュメント上の選択領域が原点から反対側の角に向かって移動することに相当します。つまり元々の原点は(下向きが正なら)画面外の左上方向に遠ざかっていくということであり、感覚的には負のオフセットがかかるわけです(#4)。
同様に拡大縮小も bounds が広くなると documentView の大きさは小さくなります。これは広角レンズを使うと一度に写る範囲が広くなる代わり、写るものの大きさが小さくなるのをイメージすると分かりやすいと思います。

magnificationとfullSizeContentView

さて、原則としてはこれでいいのですが、計算をややこしくするのが magnification と fullSizeContentView の存在です。

magnification

まず magnification ですが、NSScrollView の magnification が変化すると、NSClipView の bounds はその逆数の倍率補正が入ります。したがってオフセット量も1/倍率にしてやる必要があるわけです。ただし、倍率の影響を受けるのはあくまで NSClipView なので、bounds の計算は倍率を考慮しなければならないdocumentView の frame は特に倍率の影響を受けないという点には注意する必要があります。したがって倍率補正込みの余白計算を式にすると次のようになります。

  • (画面上の)余白 = contentView.frame - documentView.frame * 倍率
  • bounds上のオフセット = 余白 / 2 / 倍率

上のコードで言うと #3 のdeltaを計算している辺りです。なおコード上はいちいち倍率をかけたり割ったりするのは無駄なので、オフセットの/倍率は余白側に取り込んでまとめています。

fullSizeContentView

次に fullSizeContentView です。まずこれは何かというと、iOS や Mac でも Safari なんかがやっている、タイトルバーの下にコンテンツのブラー表示が潜り込むアレです。ビューの構造上はウィンドウ全体がコンテンツの表示領域となり、その上にタイトルバーをオーバーレイするような実装になっています。
NSScrollView、NSClipView ともタイトルバーを含むウィンドウ全領域の大きさを持っている点に注意してください。
fullSize.png
これはフルサイズ表示の趣旨を考えれば当たり前で、タイトルバーの下に何かを表示するためにはその下にコンテンツをスクロールできる必要があるわけです。しかし、ストレートにそれをやってしまうとタイトルバーの下にある内容が見えないということになるので、コンテンツがスクロールアウトした場合にのみタイトルバーの下に流れるという特殊な動きをとることになります。
実はこれはセンタリング計算と同じ理屈です。要するにタイトルバーの分だけ縦方向の余白を多く取ることで全体を下側にオフセットするというのが fullSizeContentView の仕掛けなわけです。

このタイトルバー(とツールバー)の大きさは NSClipView と NSScrollView が持っている contentInsets で取得できます。真面目に計算するなら四方の insets をちゃんと計算したほうがいいとは思うのですが、現実問題としてタイトルバーの潜り込み以外で insets を使うことはあまりないように思うので、ここでは insets.top のみを対象としています。ただし、NSClipView のデフォルト実装も contentInsets はちゃんと考慮してくれるので、通常は余白計算だけ考慮すれば問題ありません。上のコードで言うと、#3の deltaY の計算で frame から insets.top を引く(つまり画面に見えている大きさを計算している)処理がこれに該当します。

アニメーション

最後に実装上のトラップについて触れておきます。上のコードの #1 では拡大率を取るのに NSScrollView の magnification を使わず、わざわざ frame と proposedBounds の比で拡大率を計算していますが、実はこれは必然的な理由によるものです。
NSScrollView.animator().setMagnification(_:centeredAt:) によって拡大縮小のアニメーションを行う場合、proposedBounds で要求される拡大率と NSScrollView がプロパティとして持っている拡大率(magnification)は初期値が異なっており、アニメーションがおかしな挙動を示します。そのため本当の拡大率を取得するためには必ず proposedBounds の値を参照する必要があるわけです。
アニメーション周りは他にもトラップがあって、contentInsets の取得を enclosingScrollView ではなく NSClipView から直接取得すると、設定されるタイミングが異なるのかアニメーションにガタが出るようです。現状でもすべての場合を網羅できているか正直自信がないのですが、一般的な状況については問題のない挙動になっていると思います。

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

[Swift] 実務的Tips: Tupleのswitchで条件マトリックスをもれなく分岐させる

今日からでもすぐに取り入れられて、

  • コードをよりクリーンにできる、とか
  • 工数を削減できる、とか

そんなTipsを紹介していく記事シリーズです。
「知らなかった」「気づかなかった」「忘れていた」そんな誰かの役に立てば幸いです。

前提環境

  • Xcode 11.3.1
  • Swift 5.1.3

Tupleのswitch

var isWeekday = false
var isChildlen = false
var isSenior = false

switch (isWeekday, isChildlen, isSenior) {
case (true, true, _):
    print("平日・子ども料金")
case (true, _, true):
    print("平日・シルバー料金")
case (true, _, _):
    print("平日・大人料金")
case (false, true, _):
    print("休日・子ども料金")
case (false, _, true):
    print("休日・シルバー料金")
case (false, _, _):
    print("休日・大人料金")
}
  • Swiftのswitchは全ての組み合わせを網羅しないとコンパイルエラーにしてくれます。
  • それはTupleをswitchする場合も同様で、全要素が取りうる全組み合わせを網羅させることが容易になります。
  • 可読性はあまりよくないので、「全ての組み合わせを網羅する」ことを最優先にしたい場合以外は避けた方が良い書き方かもしれません。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AtCoderにSwiftで挑む時にPlaygroundを使うための小ワザ

AtCoderにて、Swift 5(執筆時点では5.2.1)が使えるようになりました。

プログラミング・コンテストのコーディングにはブラウザ上でコードを実行できるWebサービスを使うことが一般的かもしれませんが、、、

Playgroundだと補完機能とかコードスニペットとかいろいろと便利なので、Playgroundを使いたい。
しかし、Playgroundでは、AtCoderの問題の前提になっている標準入力が使えないのです。
(私の認識が間違っていたら教えてください。。。)

そこで、私なりの小ワザを考え、実際にコンテストにSwiftで挑戦して、ちゃんとコードを提出できたので共有します。
結果はイマイチだったので触れません(笑

サンプルコード

過去問の ABC 086 A - Product の回答コードです。
詳細はリンク先をみていただきたいですが、要するに以下のような問題です。

  • 標準入力より引数が"a b"という形式で与えられる。
  • a × bが奇数なら"Odd"と、 偶数なら"Even"と出力する。
ATCoder.playground
// for TEST
var inputs = ["3 4"]
func readLine() -> String? {
    guard !inputs.isEmpty else {
        return nil
    }
    return inputs.removeFirst()
}
// for TEST

import Foundation

func readInts() -> [Int] {
    return readLine()!.split(separator: " ").map { Int($0)! }
}

func main() {
    let ab = readInts()
    let isEven = ab[0] * ab[1] % 2 == 0
    print(isEven ? "Even" : "Odd")
}

main()
// Playgroundで実行すると出力コンソールに"Even"と表示される

解説

  • // for TESTコメントで囲まれたコードブロックは、Swiftの標準入力読み込み関数readLine()を同じ関数名で自作することで、上書きと言いますか、hookしてしまう、という意図のコードです。
  • var inputs = ["3 4"]は、問題で標準入力から与えられる引数になります。この配列の要素を変えてテストをします。
  • 問題によっては標準入力で複数行の引数が与えられるということがあります。その場合はvar inputs = ["2", "3 4", "5 6"]のように定義します。
  • コンテストにコードを提出する時は、// for TESTコメントで囲まれたコードブロック以外をコピペすればOKです。

参考リンク

AtCoder に登録したら解くべき精選過去問 10 問を Swift 5 で解いてみた

AtCoder に登録したら次にやること ~ これだけ解けば十分闘える!過去問精選 10 問 ~

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

Firebase Cloud Messagingを使ってアプリに通知機能を実装する

はじめに

本記事はFirebaseのプロジェクトが作成済みでアプリにすでにFirebaseを導入していることを前提とした内容となっています。また、iOSアプリを対象にしています。
Firebase Cloud Messaging(以降FCM)とは公式ドキュメントによると

Firebase Cloud Messaging(FCM)は、メッセージを無料で確実に送信するためのクロスプラットフォーム メッセージング ソリューションです。

ということです。通常アプリにプッシュ通知を送るにはアプリの設定以外にもAPNs(GCM)に通知送信をするためのアプリケーションサーバーを用意しないといけません。(Pusherなどのアプリで代用も可能)FCMであればAPNsに通知を送るための設定をFirebaseのコンソールから簡単に行うことができます。

プロジェクトの設定

Cloud Messagingに必要なライブラリのインストール

// Podfile
pod 'Firebase/Messaging'
// install
$ pod install

プッシュ通知に必要なCapabilityを設定

  • TARGETS -> Signing & Capability -> + を押下
  • その中からBackground ModesとPush Notificationsをダブルクリックで追加
  • Background ModesのRemote notifiationsにチェック スクリーンショット 2020-09-05 19.32.19.png この時点でプロジェクト名.entitlementsが追加されます。 中身を見ると スクリーンショット 2020-09-06 11.42.04.png development用が追加されています。他の記事を見るとこれをコピーして本番用を用意してたりしたのですが、根拠となるソースが見つからなかったのでAppleのドキュメントを漁ったところ

Xcode sets the value of the entitlement based on your app's current
provisioning profile. For example, if you're using a development provisioning
profile, Xcode sets the value to development.

プロジェクトに設定されているProvisioning Profileによって自動的に変更されるようです。
試しにProvisoning ProfileをDistributionに変えたんですが、書き換わることはありませんでした。
どうやらArchiveするときに自動的に値をセットしてくれる仕様のようです。
https://stackoverflow.com/questions/42292363/aps-environment-is-always-development
試しにアーカイブした中身を確認したところ

変更されていました。
また、AppStoreConnectにアップロードしたバイナリも確認しましたが、productionになっていました。
スクリーンショット 2020-09-06 12.15.38.png

Push用のCertificateを作成

すでにアプリのIdentifiersが用意されている前提の説明となります。はじめから作る場合はこちらが参考になるかと思います。

  • キーチェーンアクセスより証明書を発行(本番・開発用の計2つ)
  • Identifiersをクリック
  • Push Notifications -> Configure スクリーンショット 2020-09-05 19.21.36.png
  • Development SSL Certificate -> Create Certificate スクリーンショット 2020-09-05 19.22.02.png
  • 先程作った証明書を使ってCertificateを作成
  • 本番も同様に作成
  • ここまで完了するとCertificateに以下2つが作られます
    スクリーンショット 2020-09-06 12.42.37.png

  • これら2つをダウンロード

  • ~.cerをダブルクリックし、キーチェーンに登録

  • それぞれ右クリックし、「〜を書き出す」

FirebaseのCloud MessagingにAPNs証明書を登録

Firebaseコンソールの歯車を押して「プロジェクトを設定」

Settings -> Cloud MessagingからAPNs証明書の項目までスクロールし、先程作った証明書をアップロードします
スクリーンショット 2020-09-06 13.15.03.png

AppDelegate.swiftに通知を受け取るための処理を追加

AppDelegate.swiftに以下を追加

import Firebase
import FirebaseMessaging

class AppDelegate: UIResponder, UIApplicationDelegate {
...
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        FirebaseApp.configure()
        // Optional
        Messaging.messaging().delegate = self

        UNUserNotificationCenter.current().delegate = self
        // 通知の許可をユーザーに要求する
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound],completionHandler: { granted, error in
            guard error == nil else { return }
            if granted {
                // registerForRemoteNotificationsは必ずメインスレッドで実行しなければならない
                DispatchQueue.main.async {
                    // Appleプッシュ通知サービスを介してリモート通知を受信するための登録を行う
                    application.registerForRemoteNotifications()
                }
            }
        })

        return true
    }
...

}

// Optional
extension AppDelegate: MessagingDelegate {
    func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String) {
        // テスト送信用のトークン取得
        print("Firebase registration token: \(fcmToken)")
    }
}

extension AppDelegate : UNUserNotificationCenterDelegate {
    // アプリがフォアグラウンドで通知を受け取ったとき
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        let userInfo = notification.request.content.userInfo
        // Print full message.
        print(userInfo)

        // Change this to your preferred presentation option
        completionHandler([[.alert, .sound]])
    }

    // ユーザーが通知バナーをタップしたとき
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        let userInfo = response.notification.request.content.userInfo
        // Print full message.
        print(userInfo)

        completionHandler()
    }
}

実装する上で何点か注意点があります。
まず一つ目として、iOS10未満を対象とするかで通知を受信したときのデリゲートメソッドの実装が異なります。
今回iOS10未満を対象としないため上記実装となります。もし、iOS10未満も対象にする場合は公式ドキュメントを参考に場合分けが必要です。
二つ目の注意点として、registerForRemoteNotificationsを実行する際はメインスレッドで行う必要があるということです。メインスレッドでの実行を明示しない場合は以下警告がでます。
スクリーンショット 2020-09-06 16.38.52.png
三つ目はMessagingDelegateの実装です。こちらは実装しなくても通知は受け取れます。
ただ、Firebaseコンソール上での通知送信は開発・本番の区別なく送信されるため、テスト送信をしたい場合にはFCMトークンを指定してテスト送信をする必要がありました。
そのため、本番に送る前に手元で通知内容を確認したい場合にはFCMトークンを取得すると便利です。

FirebaseコンソールからPushを送る

FirebaseコンソールのCloud Messaging -> 通知の作成から新たな通知を発行できます。
スクリーンショット 2020-09-06 16.58.56.png
ここの「テストメッセージを送信」を選ぶと特定の端末に通知を送ることができます。
スクリーンショット 2020-09-06 16.57.36.png
先程のAppdelegate内のデリゲートでプリントしたFCMトークンをここに追加して「テスト」を押せば通知を受け取れます。
テスト送信ができればあとは通知設定をしていき、最後に「公開」を押せば本送信されます。
スクリーンショット 2020-09-06 17.01.00.png

参考

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

【Swift・Objective-C】iframeを含むWebViewを表示する際にshouldStartLoadWithRequest:が呼ばれてしまう時の対処法

iframeのURLに反応するshouldStartLoadWithRequestに対する対処法

以下のようにメインコンテンツとiframeかを判定することにより、iframeにshouldStartLoadWithRequestが反応しても対処ができます。

Swift

func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {

    if request.URL.absoluteString == request.mainDocumentURL?.absoluteString {
        // メインコンテンツ(読み込もうとしているWebView)の読込時の処理
    }
    // iframeのURLの読み込み時の処理
}

Objective-C

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
    if ([request.URL.absoluteString isEqualToString:request.mainDocumentURL.absoluteString]) {
        // メインコンテンツ(読み込もうとしているWebView)の読込時の処理
    }
    // iframeのURLの読み込み時の処理
}

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

[Swift5] 部分文字列を文字数から取得するときの注意点。

SwiftのStringは真剣にunicodeを扱っているらしく、大変難しいです。
参考:Swift の文字列の長さ - Qiita

部分文字列(ex:「アイウエオ」の「イウエ」)の取得も例外ではありません。Swift4までのやり方とは変わってしまい、調べてもなかなか出てこないのでメモしておきます。

まず、求めるのは「i番目の文字からj番目の文字まで」とします。例えばi=1, j=3string = "アイウエオ"ならばpartialString = イウエとなります。
以下は誤りです。

let partialString = [String.Index(utf16Offset: i, in: string)...String.Index(utf16Offset: j, in: string)]

私は最初うっかりしてこの方法を使っていたのですが、これだと次のような例でバグが起こります。

let string = "✨??✨"
let partialString = [String.Index(utf16Offset: 1, in: string)...String.Index(utf16Offset: 2, in: string)]
print(partialString) //"?"

なぜこうなってしまうのかというと、utf16Offsetとしっかり書いてある通り、あくまでここ得ているIndexutf16でのものだからです。実際

print("✨??✨".utf16.count) //6

なので、納得のいく結果でした。
確実に文字数単位で取り出したいときは、一度文字ごとに分割してから改めて次のようにします。

let string = "✨??✨"
let partialString = String(string.map{$0}[1...2])
print(partialString) //"??"

なかなか危ないところなので気をつけましょう。

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

背景に動画を流す方法

背景に動画を置く方法

画面の背景に動画をリピートで再生させ続けたい場合

viewController.swift
import UIKit
import AVFoundation

class PopupDetailViewController: UIViewController {

    var player = AVPlayer()
    let path = Bundle.main.path(forResource: "Sample", ofType: "mov")

    override func viewDidLoad() {
        super.viewDidLoad()
        player = AVPlayer(url: URL(fileURLWithPath: path!))
        player.play()

        let playerLayer = AVPlayerLayer(player: player)
//        フレームの大きさを決める
        playerLayer.frame = CGRect(x: 0, y: 0, width: view.frame.size.width, height: view.frame.size.height)
        playerLayer.videoGravity = .resizeAspectFill

        playerLayer.repeatCount = 0
        playerLayer.zPosition = -1
        view.layer.insertSublayer(playerLayer, at: 0)

//        リピートさせる
        NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { (notification) in
            self.player.seek(to: .zero)
            self.player.play()
        }
    }

動画の保存場所

スクリーンショット 2020-09-06 9.21.47.png

ここにファイルを移せば再生される

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

SwiftUIをジェネリクスでプレビューしやすくする

SwiftUIでアプリを構築していてプレビュー機能をフル活用しないことにはSwiftUI使ってる旨味を最大限に引き出せませんよね。

このWWDC動画でのプレビュー機能の活用方法がとってもわかりやすく実用的だったので、要点だけまとめて記事にします。

シンプルデータとリッチデータ

動画で語られているのが、アプリで取り扱うデータは主に2つに分類できると言及されています。

  • リッチデータ ... CoreData, RealmやCloudKitなどの情報やサーバ側にしか無いデータなど
  • シンプルデータ ... Stringなどのプリミティブなデータ型、構造体など

シンプルデータは生成や取得が容易でリッチデータは生成や取得へのレイヤーが深かったり手続きが面倒なものといったところでしょうか。

動画ではSwiftUIのView層は極力シンプルデータで構築しましょうと言っています。

仮にRealmやCloudKitのデータをViewで表示する場合であったとしてもリッチデータをそのままバインドするのではなくprotocolなどで抽象化し、シンプルデータとしてView側で表示するのを推奨しています。

リッチデータはテストやプレビューがしにくい

RealmやiCloudなどのリッチデータというのは生成がユーザ操作を経ないと出来ない場合や、ビューに必要ではない情報を多分に含んでいるケースなどが多く、テストやプレビューする時にはとても難儀です。

Realm上のデータをリストで表示みたいなユースケースを抽象化せずに実装していくとプレビューしにくいのは想像に難くないと思います。

プレビューしずらい設計

import Foundation
import SwiftUI
import RealmSwift

struct TodoView: View {
    @ObservedObject var viewModel: TodoViewModel

    var body: some View {
        List(self.viewModel.list, id: \.id) { todo in
            HStack {
                Image(systemName: todo.isComplete ? "checkmark.square" : "square")
                    .foregroundColor(todo.isComplete ? .green : .secondary)
                Text(todo.title)
            }
            .padding(6)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static let viewModel: TodoViewModel = TodoViewModel()
    static var previews: some View {
        TodoView(viewModel: viewModel)
    }
}

// MARK: viewModel

class TodoViewModel: ObservableObject {
    @Published var list: [TodoEntity]
    private var dataSource: Realm

    init() {
        self.dataSource = try! Realm()
        self.list = self.dataSource.objects(TodoEntity.self).map { $0 }
    }
}

class TodoEntity: Object, Identifiable {
    @objc dynamic var id: String = UUID().uuidString
    @objc dynamic var title: String = ""
    @objc dynamic var isComplete: Bool = false
}


TodoリストをRealmで表示するケースでの実装パターン

このままだとPreviewProviderで空データのプレビューが表示されるだけでデータがある場合のプレビューが出来ません。
抽象化しておらず、Viewが詳細な実装に依存しているため、追加処理を実装するまでデータの一覧での確認するのが辛く、あまりよろしい設計とは言えません。

今回のパターンは追加処理も簡単でプレビューしなくてもだいたいの画面はイメージできますが、より複雑なパターンや、RealmからCloudKitの載せ替えが発生した場合や、データをサーバーから取得する仕様に変更になった場合にサーバの実装が出来るまで待ち時間が発生してしまいます。
フロントエンドエンジニアとしてそれは由々しき問題なので、ビューからデータの発生源の関心を取り除きましょう。

ジェネリクスを適用しモックに差し替えやすく

import Foundation
import SwiftUI

struct TodoView<T: TodoViewModelProtocol>: View {
    @ObservedObject var viewModel: T

    var body: some View {
        List(self.viewModel.list, id: \.id) { todo in
            HStack {
                Image(systemName: todo.isComplete ? "checkmark.square" : "square")
                    .foregroundColor(todo.isComplete ? .green : .secondary)
                Text(todo.title)
            }
            .padding(6)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    class TodoViewModelMock: TodoViewModelProtocol {
        @Published var list: [TodoEntity] = [TodoEntity]()

        init() {
            self.list = [
                TodoEntity(title: "first task", isComplete: true),
                TodoEntity(title: "second task", isComplete: false),
                TodoEntity(title: "third task", isComplete: true),
            ]
        }
    }

    static var previews: some View {
        TodoView<TodoViewModelMock>(viewModel: TodoViewModelMock())
    }
}

// MARK: viewModel

protocol TodoViewModelProtocol: ObservableObject {
    associatedtype ListData: TodoEntityProtocol
    var list: [ListData] { get set }
}

protocol TodoEntityProtocol {
    var id: String { get set }
    var title: String { get set }
    var isComplete: Bool { get set }
}

class TodoEntity: Object, Identifiable, TodoEntityProtocol {
    @objc dynamic var id: String = UUID().uuidString
    @objc dynamic var title: String = ""
    @objc dynamic var isComplete: Bool = false

    convenience init(title: String, isComplete: Bool) {
        self.init()
        self.title = title
        self.isComplete = isComplete
    }
}

TodoViewModelProtocolTodoEntityProtocolを新たに定義し、抽象化

SwiftUIのView層はProtocolのみ知っている状態にし、ジェネリクスを用いて差し替え容易に

struct TodoView<T: TodoViewModelProtocol>: View {
    @ObservedObject var viewModel: T

PreviewProviderにモックデータ用のクラスを差し込めば実際のデータ状態に依存されずにプレビュー可能に。
モックデータ用のクラスはシンプルデータを自前で用意するだけで良くなりました。

モッククラスを実行ファイルに同梱したくない場合はPreview Contentフォルダに含めれば、製品版に不要なソースがバンドルされることもないので、とても有能です。

SwiftUIのプレビューはデータ設計にも強力なツール

SwiftUIのプレビューは魅力的で、簡易なビューならすぐプレビューできますが、複雑なユースケースが絡んだ場合に安直に実装してしまうと途端にプレビューしにくくなってしまいます。
これをデメリットに感じてしまう人もいるかもしれませんが、UIフレームワークが実装者に設計を意識させる作り方になっているのだと感じました。

抽象化のメリットを具体例で説明する時に毎回いい例を挙げれずに困っていましたが、今回のサンプルはいい例になるなと思い記事にしました。
UIKitは工夫しないとファットになってしまうアーキテクチャでしたが、SwiftUIは初期構想から実装者を良い設計に導くように作られているんだと思いました。

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

オプショナルへの予想を上回る気遣い

オプショナルの学習メモ

iOSアプリ開発において、オプショナルについて予想を上回る気遣いをしなければならないので、学習したことをメモしていきます。オプショナルについての内容を網羅している記事ではないのでご注意ください。

オプショナルとは?

マイページ設定の画面などには、入力すべき「必須項目」とそうでない「オプション項目」がある。学習している「オプショナル」とはここで言う「オプション項目」のことを指す。
何も値が入らない項目なので、値として「何もない = nil」をもてる変数に設定する。
※nilは空文字ではない。空文字は" "このスペース半角のように値が入っていることを表す。空文字はメモリに値を格納している。

注意すべきは、何もない(= nil)をもてることが許されるということ
言い換えれば、普通は許されない。Swiftならではの設計。

オプショナル変数の宣言方法

ポイントは型の後の?マーク!

変数にnilを代入
var hoge: String? // = nilが代入できる

?だけでなく!をつけることもあるが、少し意味が異なるので注意。

オプショナル型の変数から値を取り出す

以下にコードの例を記述した。

エラーになる計算
var age:Int? = 27
print(age + 1) //ageはオプショナル型であり整数型でないのでエラー

オプショナル型の変数前に!を加える
これをアンラップ(unwrap)という。オプショナル型はnilを許容するようにラップ(wrap)されていて、それを剥がすというイメージ
※なお、以下で行っている値を取り出す処理を行う際に、変数の直後に"!"を記述して、アンラップすることを強制的アンラップ(Forced unwrap)という

オプショナル型の変数から値を取り出す
var age:Int? = 27
print(age! + 1) // = 28

なお、nilが代入されている時に"!"をつけると、クラッシュする

nilが代入されている
var age:Int? = nil
print(age! + 1) //クラッシュ

つまり、オプショナル型の変数から値を取り出すときに"!"をつけることは、中身がnilではないことを保証しなければならない。
中身が絶対にnilではない時にしか"!"はつけてはいけない
とにかく上記が開発のミソ!!!!
中身がnilなのかどうかで設計の危険性が判断されたりする。

オプショナル型のメリット

他の言語ではnilを参照するとアプリはクラッシュする。Swiftも同様であるが、変数宣言時にnilを含むことが許されるのでnilチェックが必要じゃなくなり開発の手間が省ける。

オプショナル関係のやつをまとめていく

大まかなことは上記に記した。その他の知識や役立つTIPSは以下にまとめていく。

オプショナルチェーン

プロパティやメソッドが数珠つなぎになった形から、オプショナル型の変数を取り出す際に変数の後に?をついているもの。取り出す変数がnilなら、指定したプロパティやメソッドは実行されない。

こんな形のやつ
変数名?.メソッド.メソッド

注意
オプショナルチェーンにおいて、オプショナル型の変数名の後に"!"をつけて値がnilだった場合はアプリがクラッシュする。

オプショナルバインディング

オプショナル型の値に条件分岐をかけて値がnilかどうかで、処理が変わる
lf-let文の形になることが多いみたい

オプショナルバインディング
let age: Int?
if let me = age {
  print("合致しました")
} else {
  print("nilです") //ageの値がnilなので、else以下が実行される
} 

その他

guard let文を使うこと、map・flatmapメソッドとオプショナルの関係なども追記していきます。

参考書籍
「絶対に挫折しない iphoneアプリ開発「超」入門 第8版」

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

CocoaPodsでPodをインストールしても使えない

Swift勉強し始めたのですが、podのインストールでめちゃくちゃハマったのでその怒りをQiitaに昇華します。
新鮮な怒りをお届け。

ぶち当たったエラー

PKHUDとやらをインストールしようとして

$ pod install 

をしてimportとすると、
スクリーンショット 2020-09-05 21.08.13.png
とこんな感じで

No such module 'PKHUD'

というエラーが出た。

解決方法

.codeprojではなく、.xcworkspaceからビルドする。

stackoverflowを見ていたら、

Make sure you opened the .xcworkspace file in Xcode and not just the .xcodeproj file.

というコメントを発見し、
どうやらプロジェクトを新規で作成したときに生成される.xcodeprojから設定をいじるのと、pop install時に生成される.xcworkspaceから設定をいじるのは意味が違うということがわかった。.xcodeprojからではなく、.xcworkspaceからビルドすると、スクリーンショット 2020-09-06 1.52.57.png

import PKHUDの部分でエラーが出ずにビルドが成功しました。

結局何が原因?

.codeprojは一つのプロジェクトを管理するファイル。対して.scworkspaceは複数のプロジェクトをまとめて管理するものらしい。

CocoaPodsでインストールしたPodsは一つのプロジェクトとして管理されているので、Podsを使用したい場合は自分が作ったプロジェクト+Podsを管理しているプロジェクトの2つを一緒にビルドする必要がある。

なので、一つのプロジェクト(自分が作ったプロジェクト)を管理する.codeprojから開いてもPodsのインポートでNo module Errorが出た。(ビルドがコケた)

最後に

Podsを使った開発をするときは.scworkspaceからビルドしよう
ルールを守って楽しく開発!!!

※ (当記事はSwiftを始めて5日位の初心者です。間違いがあったら優しく指摘してね? )

参考文献

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

はじめての ReactorKit【実践編】

前回の概要編に続き、今回は実際に ReactorKit を使ったサンプル実装をしていきたいと思います。

作るアプリ

今回は Google Books API を使用したアプリを想定して実装していきたいと思います。仕様は下記の通りです。
※ 基本的には、ReactorKit 周りの実装が中心なので細かい API 処理などの実装部分などは省いていきます。

  • 画面が開いたら、API からデータを取得して TableView に反映する
  • refreshButton がタップされたら、データを更新する
  • API からデータを取得している最中は activityIndicator を表示し、取得が完了したら非表示にする

作業開始?‍?

まずは View のロジックを担う Reactor にそれぞれのイベントとデータを定義していきます。

import ReactorKit
import RxSwift
import RxCocoa // 後々 concat() 関数も使いたいのでインポート

class BookListViewReactor: Reactor {
    enum Action {
        case load
        case refresh
    }

    // Mutation を定義しない場合は、Action が Mutation として扱われる
    enum Mutation {
        case setBooks([ServerBook])
        case setLoading(Bool)
    }

    struct State {
        var books: [ServerBook]
        var isLoading: Bool
    }

    var initialState: BookListViewReactor.State = State(books: [], isLoading: false)
}

ActionStateinitialState あたりは必須 Reactor プロトコルの定義で必須で、Mutation に関しては、Action をもとに実行される処理の具体的な結果の値を定義します。また、Mutation の定義は必須ではなく、定義が無い場合には ActionMutation として扱われます。

次に Action をもとに処理を実行して Mutation を返すための mutate(action:) と、その受け取った Mutation をもとに新しい State を返す reduce(state:, mutation:) 関数を定義していきます。

    // BookListViewReactor

    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .load:
            return Observable.concat([
                Observable.just(Mutation.setLoading(true)),
                // API から本の情報一覧を取得
                BookService.sharedInstance.getAllBooks().flatMap { item in Observable.just(Mutation.setBooks(item.items))},
                Observable.just(Mutation.setLoading(false))
            ])
        case .refresh:
            return Observable.concat([
                Observable.just(Mutation.setLoading(true)),
                BookService.sharedInstance.getAllBooks().flatMap { item in Observable.just(Mutation.setBooks(item.items))},
                Observable.just(Mutation.setLoading(false))
            ])
        }
    }

    func reduce(state: State, mutation: Mutation) -> State {
        switch mutation {
        case .setLoading(let isLoading):
            var newState = state
            newState.isLoading = isLoading
            return newState
        case .setBooks(let books):
            var newState = state
            newState.books = books
            return newState
        }
    }

mutate(action:) の中では、主に Observable.cancat()Mutaiont を返しています。今回 Action として定義した load のように1つのイベントで loading の更新API からデータを取得 など複数の処理を行う必要があるので、RxCocoa が提供している concat() 関数で Observable を直列に実行して、順次 Mutatation を返しています。また、reduce(state:, mutation:) では受け取った Mutation と現在の State をもとに新しい State を発行して返しています。

これで、Reactor 側の実装は完了したので、View の実装をしていきます。

まずは、使用する View(ViewController) を ReactorKit が提供している View プロトコルに準拠させます。また、今回は、Storyboard を使用して View を作成していくので、StoryboardView というプロトコルに準拠させます。これによって、ViewController の childViews が初期化されたタイミングで bind(reactor:) が呼ばれるようになります。

import ReactorKit
import RxSwift

class BookListViewController: UIViewController, StoryboardView {
    @IBOutlet weak var refreshButton: UIButton!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    @IBOutlet weak var tableView: UITableView!
    // ? 本来はより、Testable にするために Reactor の注入はクラス内では行いませんが、今回はサンプル実装のためこのままでいきます
    var reactor: BookListViewReactor? = BookListViewReactor()

    var disposeBag: DisposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UINib(nibName: "BookTableViewCell", bundle: nil), forCellReuseIdentifier: "BookTableViewCell")
    }

    func bind(reactor: BookListViewReactor) {
        Observable.just(Void())
            .map { Reactor.Action.load }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)

        refreshButton.rx.tap
            .map { Reactor.Action.refresh }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)

        // State binding.
        reactor.state
            .map { $0.isLoading }
            .distinctUntilChanged() // 値に変更があった場合にのみイベントを流す
            .map { !$0 }
            .bind(to: activityIndicator.rx.isHidden)
            .disposed(by: disposeBag)

        reactor.state
            .map { $0.books }
            .bind(to: tableView.rx.items(cellIdentifier: "BookTableViewCell", cellType: BookTableViewCell.self)) { index, book, cell in
                cell.set(book: book)
            }
            .disposed(by: disposeBag)
    }

}

基本的には、disposeBagbind(reactor:) の定義が必須になります。今回はサンプル実装なので、reactor への反映をクラス内で行っていますが、本来はより Testable にするために、切り離す必要があります(初回表示の View の場合は、AppDelegate 内で reactor の反映を行うなど)。また、bind(reactor:) が呼ばれるのは、reactor への反映が完了していて、かつ viewDidLoad の後に呼び出されます。ですので、初回時に行う処理などを bind(reactor:)rx.methodInvoked(#selector(viewDidLoad)) のように Observe したいところですが、これは呼ばれないので初回時の処理は Observable.just(Void()) で定義します(参考のIssue-comment)。

こんな感じで、ReactorKit を使ってシンプルにアプリを作成することができました? また他の場面で使用することがあったらまた記事を書きたいと思います。

参考

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