20210302のSwiftに関する記事は16件です。

【Xcode12】Libraryを常時表示させる方法

■はじめに
・この記事ではXcode12.4を使用しています。

■方法
Libraryを常時表示するには、
『optionキーを押しながらLibraryボタンクリック』です。

スクリーンショット 2021-03-02 23.27.08.png
スクリーンショット 2021-03-02 23.24.05.png

連続してObjectsを追加する際などに有用です。

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

firebaseを使って環境分けを設定する

firebaseを利用した環境分けをする際、てこづったのでここにまとめておく。

※今回はrelease、debugの2つに分ける。
※podやfirebaseのセットアップについては省く

①まずfirebaseでアカウントを作り、プロジェクトを2つ作成する。(release用、debug用)

こんな感じでふたつ作る
スクリーンショット 2021-03-02 21.49.11.png

②両方のプロジェクトからGoogleService-Info.plistを取得する

③Xcodeのプロジェクトでフォルダをふたつ作り、分ける(debugのほうは名前を変える)

スクリーンショット 2021-03-01 22.13.02.png

④環境分けするためのファイルを追加する

FirebaseUtil.swift
import Foundation
import FirebaseCore

final class FirebaseUtil {

  static func setup() {
    #if DEBUG
    let name = "d_GoogleService-Info"
    #else
    let name = "GoogleService-Info"
    #endif
    let filePath = Bundle.main.path(forResource: name, ofType: "plist")
    if let options = FirebaseOptions(contentsOfFile: filePath!) {
      FirebaseApp.configure(options: options)
    } else {
      assertionFailure("Could'nt load config file")
    }
  }
}

⑤環境が分かれたか確認するクラッシュ用のViewControllerを追加する

CrashliticsViewController.swift
import UIKit

class CrashliticsViewController: UIViewController {


      @IBAction func tapCrash(_ sender: Any) {
        fatalError()
      }
    }

※Storyboard上のボタンと繋いで、タップするとクラッシュします。

⑥TARGETのEditSchemeを開き、releaseかdebugで分けるだけ

スクリーンショット 2021-03-02 21.42.33.png

⑦クラッシュの確認方法で少してこづったので、わかりやすい記事はこちら

⑧それぞれビルドし、クラッシュさせる

それぞれのプロジェクトのCrashlyticsを確認し、下記のように出ていたらOK
スクリーンショット 2021-03-02 21.52.35.png

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

略語(頭字語、固有名詞)のSwiftyな命名規則

Swift標準APIのURL構造体の名前に疑問符が付いたので調べてみた。
Swift公式の命名規則としてAPI Design Guidelinesがあるが、ここでは略語に関して「標準的な略語以外は省略しないで書いてね」ぐらいしか書かれていない。よって、一般的な規則である「型とプロトコルの名前はUpperCamelCase、それ以外はlowerCamelCaseで書いてね」に沿ってUrlになるはずだが、なぜかURLになっているのだ。調べてみた結果帰納的に以下の規則で結論付けたのでまとめる。

標準的な略語の命名規則

  • 単語の最初の文字の種類に揃える
  • ただし、IPv4とIPv6のvは必ず小文字

URL
CommandLineAPIHost
url
absoluteURL
IPv6Address
ipv6Settings

証拠

例外

下三つに関しては表記可能な文字で代替するようだ。C++の表記揺れも見られるので統一して欲しい。

所感

IPv6が全てを狂わせた。おそらく"IP"で一つの単語として原則に従わせ、"v6"は可読性重視で固定にしているのだと思う。
先頭に来ないiOSやmacOSはどうなるんだろう?上記の規則だとIOS、MACOSだが。。。

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

TodoAPPでRxSwift入門[part3]

概要

最近RxSwiftを勉強し始めて現在理解していることを備忘録として残せたらいいなと思い記事にします。
そもそもRxSwiftのRxとは

Rx(Reactive X)とは、「オブザーバパターン」「イテレータパターン」「関数型プログラミング」の概念を実装している拡張ライブラリです。
Rxを導入するメリットは、「値の変化を検知できる」「非同期の処理を簡潔に書ける」ということに尽きると思います。 値の変化というのは変数値の変化やUIの変化も含まれます。 例えばボタンをタッチする、という動作もボタンのステータスが変わったと捉えることができRxを使って記述することができます。

とのことです。
詳しくは以下のサイトを参照してください。
入門!RxSwift
RxSwiftについてようやく理解できてきたのでまとめることにした(1)

今回はPart3になります。
前回の記事はこちら
TodoAPPでRxSwift入門[part2]

AddTodoViewController

前回の予告通りViewControllerの中身をみていきましょう。

AddTodoViewController.swift
import UIKit
import RxSwift
import RxCocoa

class AddTodoViewController: UIViewController {

    @IBOutlet weak var titleField: UITextField!
    @IBOutlet weak var detailView: UITextView!
    @IBOutlet weak var joinButton: UIButton!
    @IBOutlet weak var showTodoListButton: UIButton!

    let disposeBag = DisposeBag()

    private var viewModel: AddTodoViewPresentable!

    override func viewDidLoad() {
        super.viewDidLoad()
        viewModel = AddTodoViewModel(input: (
            titleText: titleField.rx.text.orEmpty.asDriver(),
            detailText: detailView.rx.text.orEmpty.asDriver()
        ), storeManager: StoreManager.shared)

        setupViews()
        setupBinding()
    }

}

private extension AddTodoViewController {

    private func setupViews() {
        titleField.borderStyle = .none
    }

    private func setupBinding() {

        viewModel.output.isValid
            .drive(joinButton.rx.isEnabled)
            .disposed(by: disposeBag)

        viewModel.output.isValid.drive(onNext: { [weak self] isValid in
            self?.joinButton.backgroundColor = isValid ? .init(red: 200/255, green: 200/255, blue: 255/255, alpha: 1) : .lightGray
        }).disposed(by: disposeBag)

        titleField.rx.controlEvent(.editingDidBegin).asDriver().drive(onNext: { [weak self] in
            self?.firstResponderAnimate()
        }).disposed(by: disposeBag)

        titleField.rx.controlEvent(.editingDidEnd).asDriver().drive(onNext: { [weak self] in
            self?.resignFirstResponderAnimate()
        }).disposed(by: disposeBag)

        detailView.rx.didBeginEditing.asDriver().drive(onNext: { [weak self] in
            self?.firstResponderAnimate()
        }).disposed(by: disposeBag)

        detailView.rx.didEndEditing.asDriver().drive(onNext: { [weak self] in
            self?.resignFirstResponderAnimate()
        }).disposed(by: disposeBag)

        joinButton.rx.tap.subscribe(onNext: { [weak self] in
            guard let title = self?.titleField.text, let detail = self?.detailView.text else { return }
            self?.viewModel.insertTodoToFireStore(title: title, detail: detail)
            self?.titleField.text = ""
            self?.detailView.text = ""
        }).disposed(by: disposeBag)

        showTodoListButton.rx.tap.subscribe(onNext: { [weak self] in
            let viewController = TodoListViewController.instantiate()
            let navigationController = UINavigationController(rootViewController: viewController)
            navigationController.modalPresentationStyle = .fullScreen
            self?.present(navigationController, animated: true)
        }).disposed(by: disposeBag)
    }

    private func firstResponderAnimate() {
        let width = view.frame.size.width
        UIView.animate(withDuration: 0.3) { [weak self] in
            self?.view.transform = CGAffineTransform(translationX: 0, y: -width / 4)
        }
    }

    private func resignFirstResponderAnimate() {
        UIView.animate(withDuration: 0.3) { [weak self] in
            self?.view.transform = .identity
        }
    }
}

まずViewModelを初期化しているところからいきます。
titleText, detailTextにそれぞれの入力するUIを紐付けます。
orEmptyはアンラップ的な役割になります。

Input = (
   text: Driver<String>, ()
)

があったとしてStringを指定しているのに実際流れてくるのはString?なのでorEmptyがないとエラーになると思います。

次にsetupBindingの中をみていきます。
drivebind(to: )のようなものです。
viewModelisValidの値とjoinButton.rx.isEnabledを紐付けています。
これによって入力の文字数が足りていなかったりした時にボタンが押せないようにしたりできます。

次にcontrolEventですがこれは入力モードになった時にonNextで画面を少し上にあげるというような処理になります。

textViewも同様ですが、controlEventではなく直接どうなったかの状態を指定する形?になっています。

ボタンのところはタップした時の処理ですね。
タップしたらFirestoreにデータを保存しています。

まとめ

今回の記事を書いて思ったことはFatViewControllerを避けるためのMVVMなのに割とFatになってしまったなという感じです。
Inputでボタンがタップされたかどうかをobserveしてデータ保存の処理とかを完全にViewModelに任せてしまいたいですがまだまだ勉強が足りないですね。
また画面遷移はcoordinatorパターンですると画面遷移の処理もViewModelで検知して画面遷移するというのもできそうな気がするのですが、MVVMのそれぞれの責務を考えたときそれはViewの仕事なのかな?とも思います。
難しいところですが精進します。

次回は保存したTodoを表示します。
多分次回で終わりにします。

改善できるところや指摘がございましたらよろしくお願いします。

次はこちら。
TodoAPPでRxSwift入門[part4]

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

【Swift】MVPでYoutubeの動画検索アプリを開発してみた

はじめに

MVPについて詳しく説明している記事がありますので、お時間がある方はぜひそちらもご覧ください。

【Swift】MVPアーキテクチャについて色々調べてみた

また、今回は下記の書籍を参考にYoutubeの動画検索アプリを作成しました。
とても読みやすい本でしたのでぜひご覧ください。

iOSアプリ設計パターン入門

今回のサンプルアプリのコードはGitHubにて共有しています。

GitHubはこちら

筆者は独学で学習中のエンジニアでして、ツッコミどころ満載のコードを書くかもしれませんが、
その時は指摘していただけますと幸いです。

Passive Viewによるサンプルアプリの実装

今回は、Passive Viewでアプリを実装しようと思います。

アプリの内容としては、一つ目のViewでキーワードを入力し検索をかけると、
二つ目の画面に遷移し、Youtubeの該当する動画一覧を表示するアプリを作成したいと思います。

完成形

見づらくてすみません。
SrFpGfIqsMQGEJheybZv1614678956-1614678967.gif

コードの解説

まず必要なものをインストールします。

今回はAlamofireSDWebImageです。
ということでPodfileに下記を追記してpod installを実行します。

  pod "Alamofire"
  pod "SDWebImage"

今回のサンプルアプリを作成するにあたり、
ファイルの構造などは書籍のものを参考に作成したいと思います。

書籍では、一つの画面につき一つのフォルダを作成し、その中にStoryboard、Model、View、Presenterを作成しています。
今回は、動画検索画面(SearchVideo)と動画一覧画面(VideoList)を作成します。

作成後はこのようになります。
スクリーンショット 2021-02-27 19.04.47.jpg

Main.storyboardからSearchVideo.storyboardに変更したので、
Info.plistの値を一部変更しなければ表示されません。

下記の二箇所を自分で決めたStoryboardの名前に変更してください。
スクリーンショット 2021-02-27 19.07.16.jpg
スクリーンショット 2021-02-27 19.07.34.jpg

また、各画面のUIは次のようになります。

SearchVideoViewController(Navigation Controllerに変更)
スクリーンショット 2021-02-27 19.09.35.jpg

VideoListViewController(tableViewのみでセルはカスタムセルを使用)
スクリーンショット 2021-02-27 19.10.21.jpg

カスタムセル
スクリーンショット 2021-02-27 19.18.27.jpg

とりあえずUIはこのようにします。
各View全てAutoLayoutで制約を付けています。

次にコードを記述していきます。

MVPを意識したコードの記述、難しかったです・・・。

まずはSearchVideo関連のファイルから進めていきます。

各オブジェクトをIBOutletなどで紐付けると下記のようになります。
また、Delegateなど別のプロトコルに準拠させる場合はextensionで追加しています。
(そっちの方が見やすい気がします!)

ここから先の説明ですが、書籍の内容と個人的な解釈で記載しているので、
間違ったことを行っていたらすみません!!!

private var presenter: SearchVideoPresenterInput!について。

SearchVideoPresenterには
Input時に使うSearchVideoPresenterInputプロトコルと、
Output時に使うSearchVideoPresenterOutputプロトコルを定義しています。

つまり、Inputプロトコルには、ViewControllerからPresenterに値を渡したい時に必要な処理を、
OutputプロトコルにはPresenterからViewControllerに値を渡したい時に必要な処理を記述しています。

値を渡す時にはInputプロトコルに定義されているメソッドを使うので、
private var presenter: SearchVideoPresenterInput!のように宣言し値を渡せるようにします。

ですが、この時点では初期化をしていない状態なので、
viewDidLoad内のinject(presenter: presenter)で初期化しています。

この時、let presenter = SearchVideoPresenter(view: self)
ViewControllerの情報をPresenterに与えておきます。

SearchVideoViewController
import UIKit

class SearchVideoViewController: UIViewController {
    // 検索用のフィールド
    @IBOutlet weak var searchTextField: UITextField!

    private var presenter: SearchVideoPresenterInput!
    func inject(presenter: SearchVideoPresenterInput) {
        self.presenter = presenter
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        let presenter = SearchVideoPresenter(view: self)
        inject(presenter: presenter)

        searchTextField.delegate = self

    }

    // 検索ボタンが押された時の処理
    @IBAction func pushSearchButton(_ sender: Any) {

    }
}

extension SearchVideoViewController: UITextFieldDelegate {

    // キーボードが閉じられたら実行される
    func textFieldDidEndEditing(_ textField: UITextField) {

    }

    // return を押したらキーボードを閉じる
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        textField.resignFirstResponder()
        return true
    }

    // キーボード意外をタッチしたらキーボードを閉じる
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        searchTextField.resignFirstResponder()
    }
}

次に、一旦Presenterを記述していきます。

先ほど記述したように2つのプロトコルを定義してます。
プロトコルの中身はコメントの通りです。

Viewで何かの処理を行うたびにSearchVideoPresenterInputのメソッドが動きます。
そしてViewの画面を変更する際にはSearchVideoPresenterOutputのメソッドが動きます。

ここら辺のプロトコルに関してですが、
デリゲートなどに関してある程度理解していないとごちゃごちゃになりそうなので、
わからなかったらデリゲートについて調べてください...。

SearchVideoPresenter
import Foundation

// SearchVideoViewControllerからのInputに必要な処理を記述
protocol SearchVideoPresenterInput {
    // 入力された文字を格納するための変数
    var inputText: String {get set}
    // inputTextに文字を代入するメソッド
    func setTextToInputText(text: String?)
    // 検索ボタンを押した時の処理
    func pushSearchButton()
}

// SearchVideoViewControllerへのOutputに必要な処理を記述
protocol SearchVideoPresenterOutput {
    // 画面遷移をさせるメソッド
    func transitionToVideoList(searchText: String)
}


final class SearchVideoPresenter: SearchVideoPresenterInput {

    private var view: SearchVideoPresenterOutput!
    var inputText: String = ""

    init(view: SearchVideoViewController) {
        self.view = view
    }

    func pushSearchButton() {
        view.transitionToVideoList(searchText: inputText)
    }

    func setTextToInputText(text: String?) {
        guard let inputText = text else {
            self.inputText = ""
            return
        }

        self.inputText = inputText
    }

}

MVPは基本的に下記の流れになります。
Viewでユーザーからの操作を受け付ける。
-> 「操作されましたよ」とPresenterに伝える。
-> 必要に応じてModelで計算等を行い、結果を返してもらう
-> Viewに「〇〇してください」と指示を出す。

Viewは操作を受け付けるだけなので、
検索ボタンが押されたら、検索欄に〇〇と入力されているから画面遷移しながら〇〇の値を渡す。
と言った流れはよろしくないと思います。

なので、もっと早い段階で入力情報をPresenterで保管しておき、
画面遷移のタイミングでPresenterからViewに入力情報と画面遷移の指示を出す流れにする必要があります。

Presenterである程度プロトコルとその中身を定義したので
ViewControllerに戻って処理を記述していきます。

追記した箇所のみ記載しています。

SearchVideoViewController
import UIKit

class SearchVideoViewController: UIViewController {

    // 検索ボタンが押された時の処理
    @IBAction func pushSearchButton(_ sender: Any) {
        presenter.pushSearchButton()
    }

}

extension SearchVideoViewController: UITextFieldDelegate {

    // キーボードが閉じられたら実行される
    func textFieldDidEndEditing(_ textField: UITextField) {
        // PresenterのinputTextに値を代入
        presenter.setTextToInputText(text: textField.text)
    }

}

extension SearchVideoViewController: SearchVideoPresenterOutput {

    // 画面遷移の処理
    func transitionToVideoList(searchText: String) {

        let videoListVC = UIStoryboard(
            name: "VideoList",
            bundle: nil)
            .instantiateViewController(withIdentifier: "VideoList") as! VideoListViewController

        videoListVC.inputText = presenter.inputText

        navigationController?.pushViewController(videoListVC, animated: true)
    }

}

最終的な流れとしては下記のようになります。

文字が入力される
-> キーボードが閉じられた時にtextFieldDidEndEditing(textField:)実行
-> presenterにsetTextToInputText(text:)を実行してもらう
-> setTextToInputText(text:)実行
-> presenterのinputTextに入力情報が格納される

検索ボタンが押される
-> IBActionのpushSearchButton(sender:)が実行される
-> presenterにpushSearchButton()を実行してもらう
-> pushSearchButton()実行
-> viewにtransitionToVideoList(searchText:)を実行してもらう
-> transitionToVideoList(searchText:)実行
-> presenterの値を渡してから画面遷移

行ったり来たりして大変です(笑)

ただ、これこそがMVPの流れなのかな?とも思います。

次に画面遷移先での処理です。

画面遷移する際に入力情報の値だけ渡しているので、
それを元にYoutubeで検索し情報を取得します。

処理は先ほどの流れのような処理なので一部説明は省きます。

先ほどと同じようにviewDidLoad()でpresenteとModelの初期化を行います。
また、tableViewを使うのでdelegate = selfを忘れずに

NavigationControllerで画面を戻ることができるので、
再度VideoListViewControllerが開かれた時に再度APIを取得できるように
viewWillAppear()に記述しました。

この画面では操作は受け付けていないので、
画面が読み込まれた時にreloadData(url:, key:, text:)
実行されるだけになります。

VideoListViewController
import UIKit

class VideoListViewController: UIViewController {

    @IBOutlet weak var videoListTableView: UITableView!

    // youtubeのAPI取得のためのURLとKEY
    private let url = "https://www.googleapis.com/youtube/v3/search"
    private let key = "AIzaSyDqWqDc0lL633AinIHA9JkeVEvLR1kz1KU&part=snippet"
    var inputText: String = ""

    private var presenter: VideoListPresenterInput!
    func inject(presenter: VideoListPresenter) {
        self.presenter = presenter
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        let model = VideoListModel()
        let presenter = VideoListPresenter(view: self, model: model)
        inject(presenter: presenter)

        videoListTableView.delegate = self
        videoListTableView.dataSource = self
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // 画面読み込みと同時にAPIデータ取得
        presenter.reloadData(url: url, key: key, text: inputText)

    }
}

extension VideoListViewController: VideoListPresenterOutput {
    func reloadTableView() {
        videoListTableView.reloadData()
    }


}

extension VideoListViewController: UITableViewDelegate {
    // セルがタップされた時の処理などを記述    
}

extension VideoListViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        guard let data = presenter.data else {
            return 0
        }
        return data.items.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        videoListTableView.register(UINib(nibName: "VideoDetailCell", bundle: nil), forCellReuseIdentifier: "VideoDetailCell")
        let cell = videoListTableView.dequeueReusableCell(withIdentifier: "VideoDetailCell") as! VideoDetailCell


        if let data = presenter.data {
            cell.configre(data: data.items[indexPath.row].snippet)
        }

        return cell
    }


}

Viewが読み込まれた時にreloadData()が実行され、
reloadData()内では、ModelのgetYoutubeData()が実行されます。

getYoutubeData()はクロージャになっており、情報取得後にクロージャの内容が実行されます。
なので、取得した情報を変数dataに入れてViewにreloadTableView()を実行するよう指示します。

VideoListPresenter
import Foundation
import Alamofire

protocol VideoListPresenterInput {
    // Viewが表示されるたびに呼ばれるメソッド
    func reloadData(url: String, key: String, text: String)
    var data: VideoModel? {get set}
}

protocol VideoListPresenterOutput {
    func reloadTableView()
}



final class VideoListPresenter: VideoListPresenterInput {
    var data: VideoModel?

    private var view: VideoListPresenterOutput!
    private var model: VideoListModelInput!

    init(view: VideoListViewController, model: VideoListModel) {
        self.view = view
        self.model = model
    }

    func reloadData(url: String, key: String, text: String) {
        model.getYoutubeData(url: url, key: key, text: text) { (data) in
            self.data = data
            self.view.reloadTableView()
        }
    }
}

Modelのコードは下記のようになります。
getYoutubeData()メソッドはもらったURLと入力の情報、Keyを元にデータを取得しています。
データの取得にはAlamofireが必須なので必ずインストールしましょう。
Youtubeのデータを格納するモデル」ですが、取得したJSONのデータを格納するためのクラスになります。

ここら辺の説明はMVPとは関係ないので省略します。

VideoListModel
import Foundation
import Alamofire

// Youtubeのデータを格納するモデル
final class VideoModel: Decodable {
    let kind: String
    let items: [Item]
}


final class Item: Decodable {
    let snippet: Snippet
}

final class Snippet: Decodable {
    let publishedAt: String
    let channelId: String
    let title: String
    let description: String
    let thumbnails: Thumbnail
}

final class Thumbnail: Decodable {
    let medium: ThumbnailsInfo
    let high: ThumbnailsInfo
}

final class ThumbnailsInfo: Decodable {
    let url: String
    let width: Int?
    let height: Int?
}
// ここまでYoutubeのデータを格納するモデル



protocol VideoListModelInput {
    func getYoutubeData(url: String, key: String, text: String, completion: @escaping (_ data: VideoModel) -> Void)
}

protocol VideoListModelOutput {
    func resultAPIData(data: VideoModel)
}

final class VideoListModel: VideoListModelInput {

    // youtubeのデータを取得する
    func getYoutubeData(url: String, key: String, text: String, completion: @escaping (VideoModel) -> Void) {
        let urlString = "\(url)?q=\(text)&key=\(key)"
        AF.request(urlString).responseJSON { (response) in
            do {
                guard let data = response.data else { return }
                let decode = JSONDecoder()
                let video = try decode.decode(VideoModel.self, from: data)
                completion(video)
            } catch {
                print("変換に失敗しました。\n【エラー内容】", error)
            }
        }
    }
}

情報を取得したらPresenterのクロージャ内の処理を実行。
その処理はtableViewのリロードになっているのでtableViewが更新される。
という流れになっています。

ちなみにカスタムセルのコードは下記のようになります。
SDWebImageでサムネの画像を取得しています。

VideoDetailCell
import UIKit
import SDWebImage

class VideoDetailCell: UITableViewCell {

    @IBOutlet weak var thumbnail: UIImageView!
    @IBOutlet weak var channelIcon: UIImageView!
    @IBOutlet weak var title: UILabel!
    @IBOutlet weak var date: UILabel!

    override func awakeFromNib() {
        super.awakeFromNib()
    }

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
    }

    // セルにデータを入れていく
    func configre(data: Snippet) {
        thumbnail.sd_setImage(with: URL(string: data.thumbnails.medium.url), completed: nil)
        title.text = data.title
        date.text = data.publishedAt
    }
}

さいごに

今回は簡単な処理だったのでMVPの良さが出ているかわかりませんが、
個人的には結構面白いなと思いました。

慣れれば処理をどこに書くか迷わないでしょうし、
どこに処理を書いたかがわかりやすくなりそうです。

また、処理の流れなども掴みやすくなるかも?と思いました。

そもそも記述方法があっていなかったらすみません・・・。

MVVMなども理解すれば便利そうなので、
MVPでしっかりとコードが書けるようになったら覚えてみます!

以上、最後までご覧いただきありがとうございました。

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

【Swift】MVPアーキテクチャについて色々調べてみた

はじめに

iOSアプリ設計パターン入門という書籍を参考に記事を作成しました。
とても読みやすい本でしたのでぜひご覧ください。

通常3,500円で電子が3,200円になります。

MVPアーキテクチャ

Swiftで使われるアーキテクチャは、MVCやMVP、MVVMなどさまざまですが、
今回はMVPについて解説していきます。

MVPは画面の描画処理とプレゼンテーションロジックとを分離するアーキテクチャになり、
Passive ViewSupervising Controllerの二つのパターンが存在します。
この二つのパターンについては後ほど解説します。
MVPは、Model・View・Presenterの頭文字をとってMVPと言われています。

MVPと似ているアーキテクチャとしてあげられるMVCですが、
MVCとMVPのどちらがいいかと思う方もいると思います。

その時次第で変わるのかなと素人目線では思うのですが、
書籍を読む限り、テストを行ったり作業分担を行う際にはMVPを使用するというイメージで良いのではないかなと思います。

というのも、MVPの目的はテストを容易に行えるようにすること作業分担をしやすくすることだからです。

データの同期方法

MVPを語る上で大切になってくるのがデータの同期方法です。

MVPでは、主に二つの同期方法をとっています。
それはフロー同期オブザーバー同期です。

フロー同期

フロー同期は、上位レイヤーのデータを下位レイヤーに都度セットしてデータを同期する手続き的な同期方法です。

フロー同期のメリットとしてあげられるのは、
データの流れを掴みやすいことになります。

なぜかというと、フロー同期は画面遷移の際のpushpopの時にデータを同期させるからです。
第三者が見ても、画面遷移のコード近辺を見ればどのデータを渡しているかが容易に判断できます。

ですが、逆にデメリットもあります。
例えば、画面A -> 画面B -> 画面Cと遷移する際に、
画面Aと画面Cでは同じデータを使いたい場合があるとします。

この際、画面Aから画面Cに値を渡すことができず、画面Bを経由しなければなりません。
画面Bではそのデータを使う予定はないので、必要のないデータを保持することになります。

これは、管理が煩雑する可能性があるのであまりよろしくないと思います。

オブザーバー同期

オブザーバー同期は、監視元である下位レイヤーが監視先である上位レイヤーから
Observerを使って送られるイベント通知を受け取ってデータを同期させる、宣言的な同期方法
です。

オブザーバー同期のメリットとしてあげられるのは、
共通した監視先を持つ複数の箇所で、データを同期しやすいことです。

複数のタブや階層が離れている画面から共通の画面領域を監視しているので、
同期箇所で他の画面の参照を持つ必要がありません。

Twitterの「いいね」機能を例にすると分かりやすいかもしれません。
ユーザAがユーザBのツイートをいいねした時の処理を例にします。

ユーザAの画面
・ユーザBツイートのハートマークが赤になる
・ユーザAのプロフィールのいいね欄に追加される

ユーザBの画面
・いいねされましたの通知がくる
・自分のツイートのいいねが1つ増える

ユーザBをフォローしている人の画面
・ユーザBのツイートのいいねの数が1つ増える

ユーザAやユーザBの内容はおそらく別タブで行われているので、このような時に有効かと思います。
(もし間違っていたらすみません・・・!)

オブザーバー同期のデメリットとしては、
データが同期されるたびに変更処理を行うためいつデータが同期されるかが追いづらくなることです。

フロー同期の際は、画面遷移の時に同期されると分かっているので判断は容易ですが、
フロー同期の場合はそのように判断できないことがデメリットです。

MVPにおけるデータの同期方法

先ほど、MVPには二つのパターンがあります。と記載しましたがそれについて少し説明したいと思います。
Passive ViewSupervising Controllerですね。

Passive Viewは、Presenter -> View間に先ほど説明したフロー同期をします。

Supervising Controllerは二つの同期方法を使います。
Presenter -> View間はフロー同期し、Model -> View間はオブザーバー同期します。

単純な私は、これなら全部Supervising Controllerでいいじゃないか!と思ってしまいますが、
実際はそうでもないらしいです・・・。(設計の世界は奥が深い!!)

MVPの構造

Passive ViewとSupervising Controllerはそれぞれ違ったデータ同期方法ですが、
共通する事項も多く存在するのでそれについて記載していきます。

MVPには、Model / View / Presenter の3つのコンポーネントが存在します。
スクリーンショット 2021-02-27 13.29.43.jpg

それぞれの共通事項は下記のようになります。

共通事項

Model

Modelは、UIに関係しない純粋なロジックやデータを持ちます。
MVPにおけるModelはMVCやMVVMと同じ役割を果たしています。

Modelが扱う領域の具体例は WebAPI やデータベースへの
アクセス、BLE デバイス制御や、会員ステータスごとの商品の割引率の計算などが存在するらしいです。

また、Modelの特徴として、ViewやPresenterに依存していないので、
Modelのみでもビルドが可能であるという特徴もあげられます。

View

Viewは、ユーザの操作受付と、画面表示を担当するコンポーネントです

Viewは、タップやスワイプなどの操作を受け付けて、
Presenterに処理を委譲したりModelの処理を呼びたしたりします。

Modelに変更が発生したら、何らかの方法でViewが更新されるわけですが、
その時の更新方法の違いがPassive ViewとSupervising Controllerの違いになります。

Presenter

Presenterは、ViewとModelの仲介役です。

Modelはアプリのビジネスロジックを知っていますが、
それが画面上にどのように表示されるかを知っているべきではありません。

Viewをシンプルにするために、複雑なロジックを持たせたくはないが、
Modelに画面表示に関わるロジックを持たせたくない場合に使用するらしいです。

Presenterは、たいてい1つのViewにつき1つ作成します。

Passive View

Passive ViewはViewを完全に受け身にするパターンで
各コンポーネントのデータのやりとりはフロー同期によって実現されます。

つまり、次のような状態です。
passiviView.png

Passive Viewの利点は、Viewに描画処理の実装のみをも持たせるようにし、描画指示をPresenterに任せることで、
プレゼンテーションロジックのテストが行いやすくなる点だそうです。

なので、Passivi Viewにおける各コンポーネントの役割は下記のようになります。

コンポーネント 役割
Model Presenterからのみアクセスされ、Viewとは直接の関わりを持たない
View Presenterからの描画指示に従うだけで、完全な受け身な立ち位置
Presenter 全てのプレゼンテーションロジックを受け持つ

Supervising Controller

Supervising Controllerは、フロー同期とオブザーバー同期の両方を使うパターンです。
ViewはPresenterとはフロー同期で、Modelとはオブザーバー同期でデータのやりとりを行います。

つまりこのような形になります。
supervisingController.png

Supervising Controllerは、基本的にViewに対する入力イベントはPresenterに渡し、
必要に応じてViewに対して描画指示を出します。

Supervising Controllerの面白いところは、
Viewは簡単なプレゼンテーションロジックを持ちPresenterは複雑なプレゼンテーションロジックを持つというところです。

簡単な処理を行う時にわざわざPresenterを通すと冗長なので簡単な処理の場合は直接Modelからアクションを行います。

Supervising Controllerにおける各コンポーネントの役割は下記のようになります。

コンポーネント 役割
Model Presenterからのみアクセスされ、必要に応じてViewに対してイベントを通知する
View PresenterとModelの双方から描画指示を受け、簡単なプレゼンテーションロジックを持つ
Presenter 複雑なプレゼンテーションロジックを担う

MVPの構造まとめ

Passive Viewの流れと、Supervising Controllerの流れは下記のようになります。

Passive View
View -> Presenter -> Model -> Presenter -> View

Supervising Controller
・簡単な処理
View -> Presenter -> Model -> View
・複雑な処理
View -> Presenter -> Model -> Presenter -> View

では、どちらのパターンを使用すればいいかというところですが、
書籍では迷ったらPassive Viewを使うようにと進めています。

理由としては、Supervising Controllerの特徴の一つである
「Viewが持つ簡単なプレゼンテーションロジック」が関係してきます。

まず、この簡単の基準を開発者間で統一することが難しいです。
開発者にはプロもいればそうでない人もいるので当たり前のことですね。

その他にも、オブザーバー同期によるModelとViewの接続に使う、適切なイベントの粒度の設計が難しい。
という点もあげられていました。

私自身が経験したことはないのでこちらに関しては深く語れませんが、
確かに大規模な開発になると進むにつれボロが出てくる可能性はありそうです。

これらのことから、とりあえずSupervising ControllerではなくPassive Viewでの開発をメインにすればいいかと思います。

設計に携わるエンジニアの方々は本当に尊敬します・・・。

さいごに

今まで私は、MVCで色々開発してきましたが、
テストは切っても切り離せないことですし、チームで作業を行うにあたり、作業分担も大事になってきます。

なので、これからはMVPで開発する癖をつけていこうかなと思います。

MVPの中にも二つのパターンがありましたが、
私はとりあえずPassivi Viewをメインに学習を進めていこうと思います。
みなさんも一緒にがんばりましょう!!

この記事で学んだことと、書籍の内容をもとに
サンプルアプリを作成してみましたので下記の記事も合わせてご覧になっていただけると幸いです。

【Swift】MVPでYoutubeの動画検索アプリを開発してみた

また、今回参考にした書籍はこちらになります。
設計パターンについての書籍の中でも一番分かりやすい気がしましたのでぜひご覧ください。

iOSアプリ設計パターン入門

以上、最後までご覧いただきありがとうございました。

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

SwiftUIでリスト表示する際に画像が黒く塗り潰される

SwiftUIでリストを作成した際に画像が黒く塗りつぶされてしまったので対応方法の覚書です。

以下の様な画像と名称のみのセルを持ったシンプルなリストを定義します。

struct ContentView: View {
    var body: some View {
        VStack {
            List {
                ForEach(0..<2, id: \.self) { index in
                    Button(action: {
                        print("タップ")
                    }) {
                        SpotCell()
                    }
                }
            }
        }
    }
}

struct SpotCell: View {
    var body: some View {
        HStack(alignment: .top) {
            Spacer().frame(width: 20)
            VStack(alignment: .leading) {
                HStack(alignment: .top) {
                    VStack {
                        Spacer().frame(height: 16)
                        Image(uiImage: UIImage(named: "otaru")!)
                            .resizable()
                            .scaledToFill()
                            .frame(width: 60, height: 60)
                            .clipped()
                            .cornerRadius(4.0)
                        Spacer().frame(height: 16)
                    }
                    VStack(alignment: .leading) {
                        Spacer().frame(height: 16)
                        Text("小樽運河").font(.subheadline)
                        Spacer()
                    }
                }
            }
            Spacer()
        }
    }
}

こちらiOS14以上で実行すると問題なく画像が表示されますが、iOS13の実機で実行すると以下の様に画像が黒く塗り潰されてしまいました。
IMG_1830.PNG

iOS13以下だとImageを定義する際に.renderingMode(.original)を付けなればならない様です。
以下の様にresizableの後ろにつけることによって画像が正常に表示されました。
※リストの外に画像を配置した場合は.renderingModeを付けなくても正常に表示されるのですが、リストの中に画像を配置すると上のように黒く塗り潰されてしまう様です。

Image(uiImage: UIImage(named: "otaru")!)
                            .resizable()
                            .renderingMode(.original)
                            .scaledToFill()
                            .frame(width: 60, height: 60)
                            .clipped()
                            .cornerRadius(4.0)

IMG_1831.PNG

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

[Swift] TableViewを下スワイプして画面を更新する(UIRefreshControll)

現在開発中のアプリでTwitterのタイムラインのような、Viewを下スワイプして中身を更新する処理を追加したいと思い調査して実装。
ヒットした記事でなかなか実装出来ず多少詰まったため、記事を書くことにしました。
この記事は筆者の半ば備忘録的な記事です。

UIRefreshControllとは

画面を下に引っ張ってデータを更新するUI部品。
TwitterやYouTubeなどで皆さんが一度は見たことがある、あのクルクルです。
以下、Appleが提供するドキュメントから一部抜粋。

UIRefreshControlはUITableViewやCollectionViewを含むUIScrollViewに接続できる標準的なコントロールです。

英語でじっくり読みたいって方は一番下のリンクから飛んでみてください。
要は、スクロールビューならどれでも使えるよというやつです。

実行環境

Xcode 12.4
Swift 5.3.2

実装方法

そんな難しくはなく、すぐ実装出来ます。

   @IBOutlet weak var tableView: UITableView!  //storyboardのTableViewをアウトレット接続

   override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = self  //TableViewを使うなら必須の処理
        tableView.delegate = self  //TableViewを使うなら必須の処理
        configureRefreshControl()  //この関数を実行することで更新処理がスタート
    }

    //〜中略〜

    func configureRefreshControl () {  
       //RefreshControlを追加する処理
       tableView.refreshControl = UIRefreshControl()
       tableView.refreshControl?.addTarget(self, action: #selector(handleRefreshControl), for: .valueChanged)
    }

    @objc func handleRefreshControl() {
       /*
        更新したい処理をここに記入(データの受け取りなど)
      */

       //上記の処理が終了したら下記が実行されます。
       DispatchQueue.main.async {
          self.tableView.reloadData()  //TableViewの中身を更新する場合はここでリロード処理
          self.tableView.refreshControl?.endRefreshing()  //これを必ず記載すること
       }
    }

以上です。
コメントアウトで解説は書いているのでそちらを参考にしていただけたらと思います。

注意点としては.endRefreshing()を必ず書いてくださいということくらいです。

最後に

比較的簡単に実装出来る機能ですが、汎用性の高いUIなのかなと思います。
ぜひ、実装してみてより良いアプリ開発ライフを送っていきましょう。

ではまた。

参考

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

[Swift] TableViewを下スワイプして画面を更新する(UIRefreshControl)

現在開発中のアプリでTwitterのタイムラインのような、Viewを下スワイプして中身を更新する処理を追加したいと思い調査して実装。
ヒットした記事でなかなか実装出来ず多少詰まったため、記事を書くことにしました。
この記事は筆者の半ば備忘録的な記事です。

UIRefreshControlとは

画面を下に引っ張ってデータを更新するUI部品。
TwitterやYouTubeなどで皆さんが一度は見たことがある、あのクルクルです。
以下、Appleが提供するドキュメントから一部抜粋。

UIRefreshControlはUITableViewやCollectionViewを含むUIScrollViewに接続できる標準的なコントロールです。

英語でじっくり読みたいって方は一番下のリンクから飛んでみてください。
要は、スクロールビューならどれでも使えるよというやつです。

実行環境

Xcode 12.4
Swift 5.3.2

実装方法

そんな難しくはなく、すぐ実装出来ます。

   @IBOutlet weak var tableView: UITableView!  //storyboardのTableViewをアウトレット接続

   override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = self  //TableViewを使うなら必須の処理
        tableView.delegate = self  //TableViewを使うなら必須の処理
        configureRefreshControl()  //この関数を実行することで更新処理がスタート
    }

    //〜中略〜

    func configureRefreshControl () {  
       //RefreshControlを追加する処理
       tableView.refreshControl = UIRefreshControl()
       tableView.refreshControl?.addTarget(self, action: #selector(handleRefreshControl), for: .valueChanged)
    }

    @objc func handleRefreshControl() {
       /*
        更新したい処理をここに記入(データの受け取りなど)
      */

       //上記の処理が終了したら下記が実行されます。
       DispatchQueue.main.async {
          self.tableView.reloadData()  //TableViewの中身を更新する場合はここでリロード処理
          self.tableView.refreshControl?.endRefreshing()  //これを必ず記載すること
       }
    }

以上です。
コメントアウトで解説は書いているのでそちらを参考にしていただけたらと思います。

注意点としては.endRefreshing()を必ず書いてくださいということくらいです。

最後に

比較的簡単に実装出来る機能ですが、汎用性の高いUIなのかなと思います。
ぜひ、実装してみてより良いアプリ開発ライフを送っていきましょう。

ではまた。

参考

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

シングルトンパターンを使う時の条件とは

はじめに

タイトルにも書いてあるように、シングルトンパターンを使う時の条件を備忘録としてまとめます。

シングルトンパターンとは

デザインパターン(ソフトウェア開発における設計パターン)の一つであり、シングルトンを使って定義します。
そして、シングルトンパターンはインスタンスが1個しか生成されないことを保証したい時に使います。

シングルトンの代表例として、UserDefaultsUIApplicationなどがあります。

シングルトンの書き方

swiftでのシングルトンの書き方は、クラスに以下のコードを記述するだけです。

class Hogehoge {
  // 初回アクセスのタイミングで初期化
  static let shared = Hogehoge()
  // 外部からのインスタンス生成を禁止する
  private init() {}
}

// このようにして呼び出すことができる
Hogehoge.shared

因みにですが、structに対してシングルトンパターンは用いることが出来ません。

Copy on Write (CoW)によって暗黙的にコピーを発生させてしまうからです。

struct Fugafuga {
    var str = "original"
    static let shared = Fugafuga()
}

var fugafuga = Fugafuga.shared
// ここでCoWによってコピーが発生し、Fugafuga.sharedとは別物になってしまう
fugafuga.str = "copy"

print(fugafuga.str) // copy
print(Fugafuga.shared.str) // original

このようにコピーが発生してしまえば、インスタンスが1個では無くなってしまい
シングルトンパターンの要件を満たせないからです。

使う時の条件とは

先ほど、シングルトンパターンを使用する場面はインスタンスが1個しか生成されないことを保証したい時と書きましたが...自分にはあまりピンと来ません。

じゃあ、そういう場面とは?という感じで結局、どこで使用するか分かりません。
自分はあらゆる場所から呼べる便利さから何となく使っている感じでした。

・便利だが、危険でもある

しかし、あらゆる場所から呼べる便利さですが危険性もあります。

swiftの機能の大部分は、オブジェクト指向という考え方で設計されています。

この設計方法には3つの原則があり、その中の1つ「カプセル化」がシングルトンパターンと真逆の考え方なんですね。

・カプセル化とシングルトンパターン

カプセル化を意識すると、意図していない場所で値が好きな様に書き換えられてしまうことを防ぐことができます。

例えば、このようなコードがあったとします。

class Hogehoge {
  var str = "Hello World!"
}

外部でHogehogeクラスのインスタンスを作成した場合、今のままだとクラス内に宣言されているstrプロパティにアクセス出来てしまいます。

つまり、下記のように外部から好きな値に書き換えることが可能になります。

class Hogehoge {
  var str = "Hello World!"
}

let hogehoge = Hogehoge()
print(hogehoge.str) // Hello World!
hogehoge.str = "Goodbye World!"
print(hogehoge.str) // Goodbye World!

では、カプセル化を意識して先ほどのコードを改修してみましょう。

class Hogehoge {
  private var str = "Hello World!"
}

let hogehoge = Hogehoge()
print(hogehoge.str) // エラー
hogehoge.str = "Goodbye World!" // エラー
print(hogehoge.str) // エラー

privateをvarの前に付けました。

こうする事によって、外部からアクセスしようとしてもエラーになるので外部から値を好きな様に書き換えられてしまうことが無くなりました。

しかし、シングルトンパターンは色んな場所で呼び出せてしまうので思わぬ場面で大切なデータを書き換えてしまった等の危険性があります。

なので、適した場面で使用しなくてはいけません

シングルトンパターンの正しい使い方

Appleドキュメントで書かれていました。(やはり、公式ドキュメントを読むのが良き)

「シングルトンを使用して、グローバルにアクセス可能なクラスの共有インスタンスを提供します。サウンド効果を再生するオーディオチャンネルや、HTTPリクエストを行うネットワークマネージャのように、アプリ全体で共有されているリソースやサービスへの統一されたアクセスポイントを提供する方法として、独自のシングルトンを作成することができます。」
(引用: Managing a Shared Resource Using a Singleton | Apple Developer Document)

上記のドキュメントを読んで自分なりに解釈する

例えば、ログイン機能をアプリに実装しようと思い、新規登録画面(以下、画面A)ログイン画面(以下、画面B)を作ろうと思いました。

1.早速、実装しようとするも画面によってログインに関するインスタンスを生成するのは面倒だし、無駄にメモリを消費してしまう等の問題が...。

2.ならログイン機能を管理するクラスを作った方がいいな。

3.シングルトンパターンを使用しない場合。ログイン機能を管理するクラスのインスタンスを画面A・Bそれぞれに対して生成しなくてはならないし、更にそれぞれで何か変更を加えた際に不整合が起こる可能性があり、管理しにくい

シングルトンパターンを使用する場合。そこで、アプリ内でログイン機能を管理するクラスのインスタンスは1個のみを保証するシングルトンパターンを使用すれば、ログイン機能を管理するクラスが統一されているので不整合が起きにくく、更にはソースコードの可読性が向上し、管理しやすくなる

ということでしょうか?

おわりに

まだまだ、まとめた今でもシングルトンパターンを使えるか不安ですが
とりあえず知識は深まりました。

因みに自分の現在開発しているアプリでは、先ほどのログイン機能を管理するクラスの他にAPI通信を行うクラスもシングルトンパターンを使用しています。

メンターによると、API通信を行うクラスはシングルトンパターンにするのが主流だそうです。

参考記事

【Swift】Singleton パターンってどういう時に使うの?
Swift におけるシングルトン・staticメソッドとの付き合い方

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

【SwiftUI】sheetメソッドを使ってビューをモーダル表示にする

先日リリースした私のアプリに使用した技術の解説です。
私のアプリはこちら。

sheetメソッドとは

ビューをモーダル表示させるメソッド。下から出てきて画面の一番手前に表示される。

実際の動作

「わかった!」ボタンを押すと下から答え合わせのビューが出てきて、画面の一番手前に表示されているのがわかるかと思います。
また、このシートは上下に動かすことができ、一定の位置まで下げて指を離すと閉じるようになっています。

基本の書き方

sheetメソッド
.sheet(isPresented: ブール値, content: { 表示させたいビュー })

sheetメソッドはさまざまな引数を取りますが、一番シンプルな書き方はこのようになります。isPresentedがtrueになったとき、contentの{ }内のビューが表示されます。

ソースコード

上のサンプルアプリのコードの一部です。

ContentView.swift
import SwiftUI

struct ContentView: View {

    @State var answer = ""
    @State var moveAnswer = false //シートを出すためのブール型の変数

    var body: some View {
        VStack{
            Text("なぞなぞ")
                .font(.title)
                .padding()
            Text("パンはパンでも白黒のかわいいパンは\nぱ〜んだ?")
            TextField("こたえを書いてね", text: $answer)
                .frame(width: 200)
                .padding()
                .border(Color.black)
            // シートを出すトリガーになるボタン
            Button(action: {
                moveAnswer = true //シートを出すためのアクション
            }, label: {
                Text("わかった!")
                    .padding()
                    .background(Color.green)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            })
            //moveAnswerがtrueになったときに出てくるシート
            .sheet(isPresented: $moveAnswer, content: {
                Answer(answer: answer, moveAnswer: $moveAnswer)
            })
        }
    }
}

こちらのコードを部分ごとにわけて解説したいと思います。

シートを出すための仕組み

まず、ContentViewのプロパティとしてブール型の変数を用意します。

    @State var moveAnswer = false //シートを出すためのブール型の変数

アプリ起動時はシートが出ていない状態にしたいため、初期値にfalseを入れています。逆に言えば、初期値をtrueにすればアプリ起動時にシートを出すことができます。

次は、シートを出すトリガーになるボタンです。

// シートを出すトリガーになるボタン
            Button(action: {
                moveAnswer = true //シートを出すためのアクション
            }, label: {
                Text("わかった!")
                    .padding()
                    .background(Color.green)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            })

ボタンをタップするとmoveAnswerにtrueが代入されます。

そして、sheetメソッドを見ていきます。

            .sheet(isPresented: $moveAnswer, content: {
                Answer(answer: answer, moveAnswer: $moveAnswer)
            })

ボタンのアクションによってmoveAnswerにtrueが入るとisPresentedがtrueになり、contentのAnswerビューが表示されます。

今回、AnswerビューはAnswer.swiftという別のファイルを作って書きました。そちらのビューも見てみましょう。

今回シートに表示したビュー

Answer.swift
import SwiftUI

struct Answer: View {

    let answer: String //前の画面のなぞなぞの答え
    @Binding var moveAnswer: Bool //前の画面のmoveAnswerを反映させるための変数

    var body: some View {
        VStack{
            //なぞなぞの答えが正解かどうかで表示を変える
            if answer == "パンダ" {
                Text("せいかい!!\nすごい!天才だ!")
                    .font(.title)
                Image("correct")

            }else{
                Text("ちがうよ。もういちどもんだいをよく読んでみよう。")
                    .font(.title)
                Image("fault")
            }
            //シートを閉じるためのトリガーになるボタン
            Button(action: {
                moveAnswer = false //シートを閉じるためのアクション
            }, label: {
                Text("もどる")
                    .font(.title)
                    .padding()
            })
        }
    }
}

動画内でもやっていたように、わざわざボタンを作らなくてもシートを閉じる操作はできますが、ボタンがあった方が便利な場面もあるかと思います。このボタンの作り方について解説したいと思います。

シートを閉じるためのボタンの作り方

シートを閉じるためには、ContentViewのmoveAnswerがfalseになる必要があります。そのため、まずはContentViewのmoveAnswerをAnswerビューに反映させます。

   @Binding var moveAnswer: Bool //前の画面のmoveAnswerを反映させるための変数

@Bindingをつけることで、このmoveAnswerに入れたブール値の状態を共有しています。つまり、AnswerのmoveAnswerがfalseになると、ContentViewのmoveAnswerもfalseに変わるようにしています。

そしてこちらがシートを閉じるためのトリガーとなるボタンです。

            //シートを閉じるためのトリガーになるボタン
            Button(action: {
                moveAnswer = false //シートを閉じるためのアクション
            }, label: {
                Text("もどる")
                    .font(.title)
                    .padding()
            })

このボタンを押すことでmoveAnswerにfalseが入り、シートを閉じることができます。

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

[Swift5]UITextViewのキーボードをタップで閉じる方法(シンプル)

やりたいこと

UITextViewで文字を入力した後にキーボードを閉じたい場面があると思います。
また、Viewのどこをタップしてもキーボードを閉じることができれば便利ですよね。今回はそのような実装方法を紹介します。

コード紹介

 // Viewタップでキーボードを閉じる
 override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
     self.view.endEditing(true)
 }

以上です。

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

iOS 14 / watchOS 7 HealthKit 変更点

iOS 14 / watchOS 7 から HealthKit に症状を記録するデータタイプや、歩行に関するより詳細なデータタイプが追加されるなどの変更がありました。

ECG 関連のアップデートが中心な感じですが、2021.1 の watchOS 7.3 アップデートで日本でも ECG が使えるようになったのでまとめてみました。

心拍に関するデータタイプ HKElectrocardiogramType の追加

wathOS 7.3 (iOS 14.4) より、日本でも Apple Watch で心電図(ECG)が使えるようになりました。アメリカので二年遅れてついに日本で使えるようになりました。Apple Watch S4,5,6 で使用可能です。

https://www.apple.com/jp/newsroom/2021/01/ecg-app-and-irregular-rhythm-notification-coming-to-apple-watch/

不規則な心拍(心房細動(AFib)の兆候がある不規則な心拍リズム)の通知を受け取ることができるようになり、心電図 Apple Watch アプリを使うと、心房細動、洞調律、低心拍数、高心拍数、判定不能のいずれかに分類することができるようになっています。

HKElectrocardiogramType

この有無が記録されている HKElectrocardiogram をチェックすることが iOS14/watchOS7 SDK からできるようになりました。

let healthStore = HKHealthStore()
let healthTypes = Set([
    HKCategoryType.electrocardiogramType()
])

healthStore.requestAuthorization(toShare: nil, read: healthTypes)
    { (success, error) in
        // ...
    }

https://developer.apple.com/documentation/healthkit/hkelectrocardiogram?changes=latest_major

HKElectrocardiogram を取得するには HKSampleQuery で取得します。

let ecgType = HKObjectType.electrocardiogramType()

let ecgQuery = HKSampleQuery(sampleType: ecgType,
                             predicate: nil,
                             limit: HKObjectQueryNoLimit,
                             sortDescriptors: nil) { (query, samples, error) in
    if let error = error {
        fatalError(error.localizedDescription)
    }

    guard let ecgSamples = samples as? [HKElectrocardiogram] else {
        fatalError(String(describing: samples))
    }

    for sample in ecgSamples {
        print(sample)
    }
}

healthStore.execute(ecgQuery)

HKElectrocardiogramQuery に HKElectrocardiogram オブジェクトを渡して詳細な測定値を取得できます。

let voltageQuery = HKElectrocardiogramQuery(ecgSample) { (query, result) in
    switch(result) {

    case .measurement(let measurement):
        if let voltageQuantity = measurement.quantity(for: .appleWatchSimilarToLeadI) {
            print(voltageQuantity)
        }

    case .done:
        print("done")

    case .error(let error):
        fatalError(error.localizedDescription)

    }
}

healthStore.execute(voltageQuery)
-4.39537 mcV
1.91129 mcV
8.0672 mcV
14.0773 mcV
... 省略

HealthKit で症状を記録することができるデータタイプの追加

iOS 13.6 / watchOS 7 以降から症状についての記録のためのデータタイプが追加されています。

iOS 14 でもさらに追加され、「寒気」「鼻水が出る」「咳」「腰の痛み」「物忘れ」「頭痛」「胸の痛み」「ニキビ」など、ほか含め合計 39 種類の症状が発生したことを記録する HealthKit 対応のログアプリなどが作れるようになりました。

全て HKCategoryTypeIdentifier のデータタイプになっているので、他の HKCategoryType のサンプルのように取り扱うことができるようになっています。

https://developer.apple.com/documentation/healthkit/data_types/symptom_type_identifiers

歩行速度や歩幅、階段の昇降速度のサンプルデータ型が追加

歩くことに関するいくつかのサンプルデータが追加されました。全て HKQuantityType 型のサンプルデータになります。

6 分間歩行の推定データが取得できるようになりました。

static let sixMinuteWalkTestDistance: HKQuantityTypeIdentifier

6 分間歩行とは何か、近畿中央呼吸器センターさんのホームページに記載されている内容を引用します。

6 分間歩行試験とは、6 分間平地を歩いていただくことによって、肺や心臓の病気が日常生活の労作にどの程度障害を及ぼしているのか調べるための検査です。https://kcmc.hosp.go.jp/shinryo/hokou.html

歩行の速度や歩幅を示すデータ、さらに歩幅が左右で違う割合だとか、歩いている時に両足が地面についている瞬間の割合だとか、歩くことに関する複雑なデータが取得できるようになりました。

static let walkingSpeed: HKQuantityTypeIdentifier
static let walkingStepLength: HKQuantityTypeIdentifier
static let walkingAsymmetryPercentage: HKQuantityTypeIdentifier
static let walkingDoubleSupportPercentage: HKQuantityTypeIdentifier

階段登り下りの速度のデータが取得できるようになりました。

static let stairAscentSpeed: HKQuantityTypeIdentifier
static let stairDescentSpeed: HKQuantityTypeIdentifier

「手洗いした」データタイプが追加

watchOS 7 / Apple Watch S4,5,6 で、手洗いをマイクとモーションセンサーを使って自動で検知するようになりました。そのログデータを取り出すことができるようになります。

static let handwashingEvent: HKCategoryTypeIdentifier

HKSampleQuery で他のデータと同じようにサンプルを取り出すことができます。

SwiftUI で昨日今日の handwashingEvent を取得する例。他の HKCategoryType のデータタイプも同様の書き方で取得することができます。

import SwiftUI
import HealthKit

var healthStore: HKHealthStore!

struct ContentView: View {

    var body: some View {
        Button(action: {
            checkHealthKit()
        }, label: {
            Text("Button")
        })
    }

    func checkHealthKit() {
        healthStore = HKHealthStore()
        let healthTypes = Set([
            HKCategoryType.categoryType(forIdentifier: .handwashingEvent)!
        ])

        healthStore.requestAuthorization(toShare: nil, read: healthTypes)
            { (success, error) in
                fetchHandWashing()
            }
    }

    func fetchHandWashing() {
        let now = Date()
        let calendar = Calendar.current
        let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))
        let predicate = HKQuery.predicateForSamples(withStart: yesterday, end: now, options: [])

        let sortDescriptor = [NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: true)]
        let washingEvent = HKCategoryType.categoryType(forIdentifier: .handwashingEvent)!
        let query = HKSampleQuery(sampleType: washingEvent,
                                        predicate: predicate,
                                        limit: HKObjectQueryNoLimit,
                                        sortDescriptors: sortDescriptor) {
            (query, results, error) in

            guard error == nil else { print("error"); return }

            let format = DateFormatter()
            format.dateFormat = "yyyy-MM-dd HH:mm:ss"
            format.timeZone   = TimeZone(identifier: "Asia/Tokyo")
            print("\(format.string(from: yesterday!)) - \(format.string(from: now)) result ?:")

            if let tmpResults = results as? [HKCategorySample] {
                tmpResults.forEach { (sample) in
                    print(sample)
                }
            }
        }
        healthStore.execute(query)
    }
}

ヘッドホンから有害とされる音量を受けた時の記録のデータタイプの追加

ヘッドホンからの音、うるさすぎるよ!っていうものです。

static let headphoneAudioExposureEvent: HKCategoryTypeIdentifier

これに合わせて、環境音がうるさすぎるの時のデータタイプ名はカテゴリを正しく表記する名称に変更になりました。

~~static let audioExposureEvent: HKCategoryTypeIdentifier~~

static let environmentalAudioExposureEvent: HKCategoryTypeIdentifier

ワークアウトタイプの追加

HKWorkoutActivityTypeCardioDance
HKWorkoutActivityTypeSocialDance
HKWorkoutActivityTypePickleball
HKWorkoutActivityTypeCooldown

Cardio Dance は YouTube とかで検索するといっぱい出てきますね。

Social Dance はフォークダンスなどパートナーとか何人かでやるダンス。

Pickle Ball っていう新しいアメリカで流行り始めている?スポーツが追加されたようです。歴史的には 50 年ぐらいあるらしい。 https://www.playpickleball.com

Cooldown は運動前後のクールダウン的な運動やストレッチを明示的にするための定数ですね。Apple Fitness+ サービス(日本では提供されていませんが)のメニューに対応した定数が今後も増えそうな気がします。

(iOS 14.3 / watchOS 7.3 以降)妊娠出産に関するヘルスケアデータタイプの追加

妊娠出産関連のカテゴリタイプが追加されたので、それに対応するデータタイプが追加されています。

static let contraceptive: HKCategoryTypeIdentifier // 避妊薬
static let lactation: HKCategoryTypeIdentifier // 授乳
static let pregnancy: HKCategoryTypeIdentifier // 妊娠

(iOS 14.3/watchOS 7.2 以降) HKSample.hashUndeterminedDuration の追加

var hasUndeterminedDuration: Bool { get }

サンプルの endDate プロパティが distantFuture の場合 true になります。

distantFuture とは「遠い未来の日付」で、イベントの終了待ちをするまでのテンポラリ値として入れておくなどの使い方をしたりします。

print(Date.distantFuture)
// 4001-01-01 00:00:00 +0000

おわり

HealthKit を解説している拙著「HealthKit Book for Beginners」にも本記事を追加しましてアップデート(v3.0)しましたので、購入していただいた方は PDF 版再ダウンロードおねがいしますー。(製本版は v1.0 のままです)

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

【iOS】LiDARセンサーを用いたサンプルコード集を公開しました!

iOSのLiDARセンサーを用いたサンプルコード集をGitHubに公開したので紹介します。
本記事の執筆時点でGitHubで91スターを頂いています!

TokyoYoshida/ExampleOfiOSLiDAR
?よさそうだと思った方はスターをお願いします⭐

例えば、こんなサンプルが入っています。

iPad Pro/iPhone 12 ProにLiDARが搭載されたとき、いつかは現実世界をスキャンしてみたいと思っていたかたもいらっしゃると思いますが、このサンプルを使えば簡単に実現できてしまいます。

ビルド方法

XCodeでExampleOfiOSLiDAR.xcodeprojを開いてビルドして、iPhone 12 Pro/Pro MAXかLiDARセンサーを搭載したiPad Proにインストールします。

サンプル一覧

Depth Map

深度情報を画像にしたものです。スクリーンショットは静止画ですがリアルタイムに動作します。

Confidence Map

深度情報の信頼度を画像にしたものです。リアルタイムに動作します。

Collision

現実世界に、仮想的なオブジェクトがぶつかったときの判定をします。

Export

現実世界のオブジェクトをスキャンして、.objファイルにエクスポートします。
エクスポートしたファイルはXcode,Macのプレビューなどで閲覧したり、Blenderで編集できます。


?

Scan with Texture

現実世界のオブジェクトをカラースキャンします。こちらは、サンプルアプリ内でのみ閲覧できます。

最後に

LiDARセンサーはなかなかおもしろい技術なので、これからも追っていきたいと思っています。作品やサンプルができたら、Twitterで発信していきますのでフォローをお願いします?

Twitter
https://twitter.com/jugemjugemjugem

Note こちらはiOS開発、AR、機械学習などについては発信しています。
https://note.com/tokyoyoshida

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

[ARKit]水平面の検出

水平面を検出するためのコンフィグレーションを設定

現実世界の水平面を検出するためにの設定をします。
sceneViewの型はARSCNViewクラスです。デフォルトでは、平面検出は行われません。必要な機能はプログラマーが各自設定する必要があります。

   let configuration=ARWorldTrackingConfiguration()
//水平面の時は.horizontalで垂直面の時は.verticalになります。
   configuration.planeDetection = .horizontal
   sceneView.session.run(configuration)

検出した平面をどのように扱うのか?

ARSessionDelegateを処理したいクラスに批准させて、そのクラスをARSceneView.session.delegateに代入して、デリゲートメソッドを利用します。

sceneView.session.delegate=self

ここで、selfを代入させたので、このクラス(Viewcontroller)上でデリゲートメソッドを利用できます。

//アンカーがセッションに追加されたときに呼ばれる
func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {

    }
//アンカーが更新されたときに呼ばれる
func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {

    }

アンカーとは検出物体の位置や向きを表すクラスです。
ARanchorクラスはプロパティとしてindentifierとtransformを持っています。
indentifierはそのアンカーの名前です。人間でも名前で他人と区別するように、アンカーにも名前をつけて他のアンカーと区別をします。つまり、アンカーを一意に定めるものです。transformは、位置や大きさを表すプロパティで4×4行列です。
ここで、anchorのままでは使いにくいので,サブクラスであるARPlaneAnchorにキャストします。

//アンカーがセッションに追加されたときに呼ばれる
func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
     guard let planeAnchors=anchor as? [ARPlaneAnchor] else {return}

    }

ARPlaneanchorクラスは,centerプロパティとextentプロパティを持ちます。
centerプロパティは平面アンカーの中心の位置で、extent(simd_float3型)は,平面の大きさを表しますが、平面なのでyの値は0になります。
検出した平面に色を塗りたいときこのメソッドは使いません。
AR機能はセッション単位で管理しています。
上のメソッドでは、平面に限らず、seceViewにアンカーが追加されたり、更新されたりするときに呼ばれだけで検出した平面のアンカーの位置や大きさを所得して、色を変えたりするのは別のメソッドで行います。

平面検出したときに呼ばれるメソッド

ARSCNViewDelegateプロトコルのデリゲートメソッドを使います。

//平面アンカーのノードがシーンに追加されたときに呼ばれる
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
//      アンカーを平面アンカーにキャストする
        guard let planeAnchor=anchor as? ARPlaneAnchor else {return}
//      アンカーから平面形状(geometry)の平面を作成
        let geometry=SCNPlane(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z))
//      平面に黄色を付ける
     geometry.materials.first?.diffuse.contents=UIColor.yellow.withAlphaComponent(0.8)
//      平面からノードを作成する。        
        let planeNode=SCNNode(geometry: geometry)
//     なんか知らんけど、平面がx-y平面上に作られるので,平面をx軸(1,0,0)を軸に90°回す
        planeNode.transform=SCNMatrix4MakeRotation(-Float.pi/2, 1, 0, 0)
//      メインスレッドでnodeに追加する
        DispatchQueue.main.async(execute:{
            node.addChildNode(planeNode)
        })
    }

nodeは平面アンカーに対応したnodeです。

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

Swiftでカメラキャプチャーするための最小コード

準備

Info.plist に以下の設定を追加。

    <key>NSCameraUsageDescription</key>
    <string>[カメラ使用に関する説明]</string>

コード

import UIKit
import AVFoundation

class CaptureViewController: UIViewController {

    private let previewLayer: AVCaptureVideoPreviewLayer = {
        let previewLayer = AVCaptureVideoPreviewLayer()
        previewLayer.videoGravity = .resizeAspectFill
        return previewLayer
    }()

    init() {
        super.init(nibName: nil, bundle: nil)

        let session = AVCaptureSession()
        previewLayer.session = session

        if let device = AVCaptureDevice.default(for: .video),
           let input = try? AVCaptureDeviceInput(device: device),
           session.canAddInput(input) {
            session.addInput(input)
        }

        let output = AVCaptureVideoDataOutput()
        output.setSampleBufferDelegate(self, queue: .global())
        if session.canAddOutput(output) {
            session.addOutput(output)
            output.connection(with: .video)?.videoOrientation = .portrait
        }

        session.startRunning()
    }

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

    override func viewDidLoad() {
        super.viewDidLoad()

        view.layer.addSublayer(previewLayer)
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        previewLayer.frame = CGRect(origin: .zero, size: view.bounds.size)
    }

}

extension CaptureViewController: AVCaptureVideoDataOutputSampleBufferDelegate {

    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        // sampleBufferを処理する
    }

}

環境

Xcode 12.2

Apple Swift version 5.3.1 (swiftlang-1200.0.41 clang-1200.0.32.8)
Target: x86_64-apple-darwin19.6.0
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む