- 投稿日:2020-02-16T23:57:54+09:00
Human Interface GuidelineにおけるModalのUIBarButtonItemの規則
iOS13になってから標準でmodalがiOSアプリ内でも使えるようになり、色々なアプリでmodalをみるようになったと思います。今回は凄いニッチな話ですが、UINavigationControllerで表示したmodalのUIBarButtonItemの命名規則についてまとめていきます。
想定する読者
- アプリ開発をしているUIデザイナー
- iOSエンジニア
- Human Interface Guidelinesを勉強したい人
この記事のゴール
この記事を読んだ人・書いた僕自身が今後理由なくしてmodalのUIBarButtonItemの実装でHIGに従ってないアプリを作成しないこと
そもそもmodalとは
Human Interface Guidelinesによると
Modality is a design technique that presents content in a temporary mode that’s separate from the user's previous current context and requires an explicit action to exit. Presenting content modally can:
Help people focus on a self-contained task or set of closely related options
Ensure that people receive and, if necessary, act on critical information日本語訳すると
modalとはユーザーにとって、前のコンテキストとは別の一時的な状態でコンテンツを提示するデザイン手法であり、終了するには明示的なアクションが必要なものである。モーダルは
- 自己完結型のタスクまたは強い関連のある一連のタスクにユーザーが集中できるようにする
- 人々が重要な情報を受け取り、必要に応じて行動する
ものである。
つまり、簡潔に言うとモーダルは
- タスクを行うためのもの
- タスクを完了するか、放棄することで閉じるもの
であることがわかります。
サンプル画像
いくつかAppleが出しているiOSアプリのnavigationBarのスクショを掲載します。
サンプル画像を見てわかること
キャンセルボタンと完了を表すボタンのみ
どの画面にもキャンセルまたは完了(を意味するボタン)がついています。
完了と同じ意味なら「完了」でなくていい
完了の他にも「追加」「投稿」など完了の意味を内包していれば完了を使わなくても良さそうなのが読み取れます。
アイコンは使っていない
僕は✖️のアイコンとかも使用すると勝手に思ってたんですが、Appleのアプリには存在しなさそうな雰囲気を感じます。
また、完了に似た「閉じる」のような文言も使ってないようです。キャンセルはfontWeightがregular
タスクを完了することが目的なのでタスクを完了せず離脱する場合はregularみたいです。
完了を表すボタンはfontWeightがbold
逆に完了する場合はboldを使うのが推奨されるみたいです。
コードベースで見てみる
Documentでもこれまでの話と同じことがより簡潔に述べれられています。(この部分はmodalだけの話ではないです)
)なのでこれを実行すると下の画像のようにdone(完了とか)では勝手にboldになるし、plain(キャンセルとか)ではregularになります。
自分の作成してるiOSアプリや普段利用しているアプリがどうなってるのか見てみると面白いかもしれません。
- 投稿日:2020-02-16T22:42:28+09:00
【Swift5】UnitテストでArray(配列)のMatchableを作った時の備忘録
はじめに
Cuckooを使ったUnitテストで、
Array(配列)のMatchableを作成した際の備忘録を残しておきます。
なお、誤った記述等ございましたら、コメントをいただけますと幸いです。実際に使用した用途・目的
Bluetoothクラスの実装で、
discoverCharacteristics(_:for:)メソッド
のテストコードを書く際に、このメソッドの引数である[CBUUID]?をMatchableに適合する必要がありました。CBUUIDは、Bluetoothで使用する特殊な型です。
CBUUIDの使い所については【Swift5】Bluetoothクラス実装の備忘録にも記載しておりますので、宜しければご覧ください。エラー内容
Argument type '[CBUUID]' does not conform to expected type 'OptionalMatchable'実際にテストした内容
discoverCharacteristics(_:for:)メソッド
を呼んだ際に、引数のCBUUIDに適切な値がセットされて呼ばれているのかをテストします。_ = XCTContext.runActivity(named: "Peripheral.discoverCharacteristics is called.", block: { _ in let uuidArray: [CBUUID] = [CBUUID(string: Const.Bluetooth.kUUID01), CBUUID(string: Const.Bluetooth.kUUID02), CBUUID(string: Const.Bluetooth.kUUID03)] verify(self.peripheral, times(1)).discoverCharacteristics(uuidArray, for: any()) })解決方法・コード
エラーの解消には以下の2つのextensionが必要でした。
MatchableExtensions.swiftextension Array: OptionalMatchable where Element == CBUUID { public var optionalMatcher: ParameterMatcher<[CBUUID]?> { return equal(to: self) } } extension CBUUID: OptionalMatchable { public var optionalMatcher: ParameterMatcher<CBUUID?> { return equal(to: self) } }今回は、CBUUID?の配列に適合する必要があったので、Arrayのextensionと、CBUUIDのextensionの2つが必要でした。
まとめ
Cuckooを使っているからといって特殊な対応が必要というわけではなく、
Arrayのextensionが作れれば問題なし!といった感じでした。応用も、基礎知識を持っていれば対応できるのですね・・・!
参考
- 投稿日:2020-02-16T21:34:58+09:00
【Swift5】超初心者でもできるCocoaPodsのインストール、ライブラリのインストールの仕方
はじめに
半年前くらいにSwiftを触りはじめました。
最初の頃は全くわからず、ネットで調べながらやっても、CocoaPodsの導入すらできませんでした。
ネットでCocoaPodsの導入に関する記事はたくさんあり、わかりやすく解説されているのですが、
超初心者には、この説明でもわかりませんでした。ということで、僕みたいな超初心者でもCocoaPodsでライブラリをインストールできるよう、画像付きで解説しました。
CocoaPodsの導入の仕方
- 投稿日:2020-02-16T20:45:41+09:00
UITextViewにPlaceHolderを折り返し有りで追加してみる
概要
UITextViewにプレースホルダーを追加してみました。
かつ、プレースホルダーには折り返しをしてくれるようにしてみました。はじめに
UITextViewにプレースホルダーを追加するだけのやり方であれば、
パイセン等が分かりやすくて素晴らしい記事を公開されてるので、そちらを参考にしてみてください。UITextViewにプレースホルダーを設定できるようにする(Swift4)
[Swift 4.2] UITextViewにプレースホルダーを追加する[iOS 12]私自身も大変参考にさせて頂きました?♂️
ありがとうございます?♂️?♂️?♂️
というか、ほぼほぼベースはパクらせてもらっこんなの
まずはじめに、単純にプレースホルダーをUILabelで追加しただけであれば、
こんな感じに折り返しが効かずに見切れてしまっています。※『UITextViewのPlaceHolderをここに表示してます。』という文言を設定
ソースコード
プレースホルダーの表示用として、UILabelをaddSubViewしており、
その際に、superViewであるUITextViewに対してAutolayoutの設定を入れることにより、折り返して表示してくれるようになってます。PlaceHolderTextView.swiftimport UIKit @IBDesignable class PlaceHolderTextView: UITextView { private let placeHolderLabelOriginPosition = CGPoint(x: 5.0, y: 8.0) lazy private var placeHolderLabel: UILabel = { let label = UILabel() label.numberOfLines = 0 label.font = font label.backgroundColor = .clear label.translatesAutoresizingMaskIntoConstraints = false addSubview(label) let labelWidth = frame.width - placeHolderLabelOriginPosition.x * 2 NSLayoutConstraint.activate([ label.topAnchor.constraint(equalTo: topAnchor, constant: placeHolderLabelOriginPosition.y), label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: placeHolderLabelOriginPosition.x), label.widthAnchor.constraint(equalToConstant: labelWidth) ]) return label }() @IBInspectable var placeHolder: String = "" { didSet { reloadView() } } @IBInspectable var placeHolderColor: UIColor = .lightGray { didSet { reloadView() } } override var text: String! { didSet { changeVisiblePlaceHolder() } } override func awakeFromNib() { super.awakeFromNib() changeVisiblePlaceHolder() NotificationCenter.default.addObserver(self, selector: #selector(textChanged), name: UITextView.textDidChangeNotification, object: nil) } } extension PlaceHolderTextView { private func reloadView() { placeHolderLabel.text = placeHolder placeHolderLabel.textColor = placeHolderColor changeVisiblePlaceHolder() } private func changeVisiblePlaceHolder() { placeHolderLabel.isHidden = placeHolder.isEmpty || !text.isEmpty ? true : false } @objc private func textChanged(notification: NSNotification?) { changeVisiblePlaceHolder() } }おわりに
そもそも、プレースホルダーに折り返しが必要になるほどの長文を書くシチュエーションに遭遇するのでしょうか・・・?
追記
@am10 さんからのご指摘&ご提案から、コードベースでテキスト代入時にもプレースホルダーの表示切替が可能になりました!
- 投稿日:2020-02-16T16:49:58+09:00
Git リポジトリ検索APIをステップ・バイ・ステップでリファクタリング
はじめに
検証コードを書く時にGithub APIを使うことが多いですが、APIの種類を増やすのにコピー&ペーストして作成してしまっています。
”コピー&ペーストで重複したコードを増やさないようにしたい”、また、”データ通信ではなくJsonからデータ取得するテストコードと共存させたい” という動機からリファクタリングをしていきます。
今回リファクタリングするにあたっては、
【iOSDC2019 補足資料】具体的なコードから始めよ ~今の問題を解決し、ジェネリックなコードを見出す を参考にさせてもらっています。
いつも、とても勉強になる投稿本当にありがとうございます。まずリファクタリング前のオリジナルのコードを見てみましょう。
リポジトリ検索のオリジナルコード
import Combine import Foundation protocol RepoService { func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> } class RepoServiceImpl: RepoService { private let session: URLSession private let decoder: JSONDecoder init(session: URLSession = .shared, decoder: JSONDecoder = .init()) { self.session = session self.decoder = decoder } func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> { guard var urlComponents = URLComponents(string: "https://api.github.com/search/repositories") else { preconditionFailure("Can't create url components...") } urlComponents.queryItems = [ URLQueryItem(name: "q", value: query) ] guard let url = urlComponents.url else { preconditionFailure("Can't create url from url components...") } return session .dataTaskPublisher(for: url) .map { $0.data } .decode(type: RepoResponse.self, decoder: decoder) .map { $0.items } .eraseToAnyPublisher() } } struct RepoResponse: Decodable { let items: [Repo] }Combineフレームワークを使っているので、スッキリ書けています。
このAPI一本だけなら、このように書くので良いと思います。ただ、ユーザー検索APIを追加する時、上のコードをコピーして、以下の3箇所を変更することになり、共通点がとても多いことに気づきます。
- エンドポイント変更
var urlComponents = URLComponents(string: "https://api.github.com/search/repositories") ↓ var urlComponents = URLComponents(string: "https://api.github.com/search/users")
- エンティティの型の変更
-> AnyPublisher<[Repo], Error> { ↓ -> AnyPublisher<[User], Error> {
- デコーダーの型の変更
.decode(type: RepoResponse.self, decoder: decoder) ↓ .decode(type: UserResponse.self, decoder: decoder)また、テストコードを書く場合にどうしたら良いのかも検討したいと思います。
コードの改善
共通部分の洗い出し&方針
- APIのURLは、
/repositories
/users
のエンドポイント以外は完全一致しているので、エンドポイントを切り出せば良さそう。- Decode時に指定している型が
RepoResponse
、UserResponse
と異なりますが、Genericsを使えば解決できそう。- 戻りの型が
-> AnyPublisher<[Repo], Error>
となっていますが、これもGenericsを使えば解決できそう。オフィシャルページを確認してみます。今回使用するこの2つはSearch機能に属するAPIです。パラメータは
- q
- sort
- order
と共通です。
APIのResponseも itemsの外側は完全に一致しています。
{ "total_count": 1, "incomplete_results": false, "items": [ { // ... } ] }尚、Repositories のAPIとの共通化は検討しないものとします。
共通部分をAPIClientとして作成
APIのレスポンスを格納している、RepoResponse
はitems
のみをプロパティに持つStructです。struct RepoResponse: Decodable { let items: [Repo] }汎用化するにあたり、
Repo
をassociatedtype
で表現すれば解決できそうです。
/repositories
のエンドポイントをapiBase
として定義します。この2点を加味すると以下のようなProtocolが作れます。
protocol Fetchable: Decodable { associatedtype Response static var apiBase: String { get } var items: [Response] { get set } }Fetchableに準拠させて、RepoResponseは以下のように書きかえられます。
今回は、型推論が効くのでtypealiasを指定を省略しています。struct RepoResponse: Fetchable { var items: [Repo] static var apiBase: String { "/repositories" } }次に、
RepoServiceImpl
とUserServiceImpl
で共通部分を抽出すると以下のようになります。final class APIClient { let baseURL = "https://api.github.com/search" let session = URLSession.shared func fetch<Model>(_: Model.Type, query: String) -> AnyPublisher<[Model.Response], Error> where Model: Fetchable { guard var urlComponents = URLComponents(string: "\(baseURL)\(Model.apiBase)") else { preconditionFailure("Can't create url components...") } urlComponents.queryItems = [ URLQueryItem(name: "q", value: query) ] guard let url = urlComponents.url else { preconditionFailure("Cant't create url from url components...") } return session.dataTaskPublisher(for: url) .map { $0.data } .decode(type: Model.self, decoder: JSONDecoder()) .map { $0.items } .eraseToAnyPublisher() } }Genericsの
Model
は、where Model: Fetchable
により、Fetchableに準拠させています。これによりModelにapiBaseを定義していることになるので、APIのURLをbaseURL + apiBaseで表現することができるようになります。
戻り値は
-> AnyPublisher<[Model.Response]
となります。decoderは
.decode(type: Model.self, decoder: JSONDecoder())
となります(型を指定する必要があるので、 Model.selfとしています)
func fetch<Model>(_: Model.Type, ...)
このModel.Typeは関数の内部で使用されていませんが何なのでしょうか。呼び出し側で、
apiClient.fetch(RepoResponse.self, ...)とすることで
Model == RepoResponse
が成り立ちます。
つまり、Genericsの型を指定するためのパラメータであることがわかります。
Modelではなく、Model.Typeとしているのは、インスタンスではなく型を受け取るためです。
APIClient
に共通部分の処理を移管できましたので、RepoServiceImpl
は以下のように書けます。大分スッキリしました!final class RepoServiceImpl: RepoService { private let apiClient: APIClient init() { apiClient = APIClient() } func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> { apiClient.fetch(RepoResponse.self, query: query) } }テスト可能なコードに書き換え
上のコードは、URLSessionを使う前提の作りとなっているため、API通信が必要となってしまっています。
JSONを読み込んでその結果を返却するようなテスト用のMockコードを作成するには、APIClientが使えないので、
RepoServiceImpl
とは独立した以下のようなClassを作成する必要があります。final class RepoServiceMock: RepoService { func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> { // load data from json let data = repoData.items if data.isEmpty { let error = FetchError.parsing(description: "Couldn't load data") return Fail(error: error).eraseToAnyPublisher() } else { return Future<[Repo], Error> { promise in promise(.success(data)) }.eraseToAnyPublisher() } } } let repoData: RepoResponse = load("repositories.json") func load<T: Decodable>(_ filename: String, as type: T.Type = T.self) -> T { let data: Data guard let file = Bundle.main.url(forResource: filename, withExtension: nil) else { fatalError("Couldn't find \(filename) in main bundle.") } do { data = try Data(contentsOf: file) } catch { fatalError("Couldn't load \(filename) from main bundle:\n\(error)") } do { let decoder = JSONDecoder() return try decoder.decode(T.self, from: data) } catch { fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)") } }APIClient内部でURLSessionが定義されていなければ、この RepoServiceMock は RepoServiceImpl と共通化できそうです。
では、URLSessionを使わないように書き換えるにあたり、どの部分まで抽出できるか考えます。
return session.dataTaskPublisher(for: url) .map { $0.data } .decode(type: Model.self, decoder: JSONDecoder()) .map { $0.items } .eraseToAnyPublisher()Decode部分はGenericsで型指定されているので、
return session.dataTaskPublisher(for: url) .map { $0.data }その前の部分を切り出せれば良さそうです。
dataTaskPublisherについて調査
dataTaskPublisher
が何か調べていきます。このdataTaskPublisherはURLSessionの中にStructとして定義されているため、URLSessionありきのものであることがわかります。
extension URLSession { ... public func dataTaskPublisher(for url: URL) -> URLSession.DataTaskPublisher public struct DataTaskPublisher : Publisher { /// The kind of values published by this publisher. public typealias Output = (data: Data, response: URLResponse) /// The kind of errors this publisher might publish. /// /// Use `Never` if this `Publisher` does not publish errors. public typealias Failure = URLError public let request: URLRequest public let session: URLSession public init(request: URLRequest, session: URLSession) /// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)` /// /// - SeeAlso: `subscribe(_:)` /// - Parameters: /// - subscriber: The subscriber to attach to this `Publisher`. /// once attached it can begin to receive values. public func receive<S>(subscriber: S) where S : Subscriber, S.Failure == URLSession.DataTaskPublisher.Failure, S.Input == URLSession.DataTaskPublisher.Output } }map { $0.data } はどんな返却値なの?
struct DataTaskPublisher : Publisher
と定義されているので、
mapはPublisherで定義されているものとなります。extension Publisher { /// Transforms all elements from the upstream publisher with a provided closure. /// /// - Parameter transform: A closure that takes one element as its parameter and returns a new element. /// - Returns: A publisher that uses the provided closure to map elements from the upstream publisher to new elements that it then publishes. public func map<T>(_ transform: @escaping (Self.Output) -> T) -> Publishers.Map<Self, T> }戻り値の型が、
Publishers.Map<Self, T>
となっていますが、Tはtransformで変換した戻り値の型であることがわかります。今回はData型に変換しているので、 T == Dataとなります。
Selfは何でしょうか?これは、自分自身 つまり、
URLSession.DataTaskPublisher
となります。つまり、URLSessionの一部の DataTaskPublisher を返却するPublisherなのでURLSessionから独立できません。
ほしいのはData型じゃないの?
今回リファクタリングするにこの発想に至るのが時間がかかりました。このままの形ではデータはURLSessionから独立できないので、データの型を
AnyPublisher<Data, URLError>
に変換してしまえば良いのではないでしょうか。Transportの定義
AnyPublisher<Data, URLError>
を戻り値の関数を持つ、Transportプロトコルを定義します。
AnyPublisher
に変換するため、.eraseToAnyPublisher()
を追加します。protocol Transport { func fetch(for url: URL) -> AnyPublisher<Data, URLError> } extension URLSession: Transport { func fetch(for url: URL) -> AnyPublisher<Data, URLError> { dataTaskPublisher(for: url).map { $0.data }.eraseToAnyPublisher() } }良さそうですね。 Retroactive Modelingも活用して、URLSessionにfetch関数を追加しています。これで、APIClientからURLSessionを排除できそうです。
APIClientは以下のように書き換えられます。
コンストラクタ時に、URLSession.shared = Transportとしているので今まで通り機能します。
リファクタリングする際にパラメータはq
以外に、sort
、order
も指定できるように変更しました。final class APIClient { let baseURL = "https://api.github.com/search" let transport: Transport init(transport: Transport = URLSession.shared) { self.transport = transport } func fetch<Model>(_: Model.Type, queries: [URLQueryItem]) -> AnyPublisher<[Model.Response], Error> where Model: Fetchable { guard var urlComponents = URLComponents(string: "\(baseURL)\(Model.apiBase)") else { preconditionFailure("Can't create url components...") } if !queries.isEmpty { urlComponents.queryItems = queries } guard let url = urlComponents.url else { preconditionFailure("Cant't create url from url components...") } return transport.fetch(for: url) .decode(type: Model.self, decoder: JSONDecoder()) .map { $0.items } .eraseToAnyPublisher() } }コンストラクタとして、URLSession.sharedを受け取りますが、fetch関数の中にはURLSessionの記述はなくなりました。
利用する側は以下のようになります。
final class RepoServiceImpl: RepoService { private let apiClient: APIClient init(transport: Transport = URLSession.shared) { apiClient = APIClient(transport: transport) } func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> { apiClient.fetch(RepoResponse.self, queries: [URLQueryItem(name: "q", value: query)]) } }テスト用のコードを作成
Jsonデータを読み込んだテストコードは、Transportに準拠させれば良いので、以下のようにシンプルに書けます。これでテスト用のコードでもAPIClientを使用するようにできました。
APIリクエストしてData型を取得、Jsonファイルを読み込んでData型を取得の部分が違うだけで後は処理が共通になりました。
final class TestRepoTransport: Transport { func fetch(for url: URL) -> AnyPublisher<Data, URLError> { let data = loadRawData("repositories.json") return Future<Data, URLError> { callback in callback(.success(data)) } .eraseToAnyPublisher() } } func loadRawData(_ filename: String) -> Data { let data: Data guard let file = Bundle.main.url(forResource: filename, withExtension: nil) else { fatalError("Couldn't find \(filename) in main bundle.") } do { data = try Data(contentsOf: file) } catch { fatalError("Couldn't load \(filename) from main bundle:\n\(error)") } return data }RepoServiceImplのイニシャライズ時に、
API通信させたい場合は、RepoServiceImpl()
、
Jsonからデータを取得したい場合は、RepoServiceImpl(transport: TestRepoTransport())
とするだけで差し替え可能になりました。ソース全文
- RepoService
import Combine import Foundation protocol RepoService { func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> } final class RepoServiceImpl: RepoService { private let apiClient: APIClient init(transport: Transport = URLSession.shared) { apiClient = APIClient(transport: transport) } func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> { apiClient.fetch(RepoResponse.self, queries: [URLQueryItem(name: "q", value: query)]) } } // MARK: - Test final class TestRepoTransport: Transport { func fetch(for url: URL) -> AnyPublisher<Data, URLError> { let data = loadRawData("repositories.json") return Future<Data, URLError> { callback in callback(.success(data)) } .eraseToAnyPublisher() } }
- APIClient
import Combine import Foundation final class APIClient { let baseURL = "https://api.github.com/search" let transport: Transport init(transport: Transport = URLSession.shared) { self.transport = transport } func fetch<Model>(_: Model.Type, queries: [URLQueryItem]) -> AnyPublisher<[Model.Response], Error> where Model: Fetchable { guard var urlComponents = URLComponents(string: "\(baseURL)\(Model.apiBase)") else { preconditionFailure("Can't create url components...") } if !queries.isEmpty { urlComponents.queryItems = queries } guard let url = urlComponents.url else { preconditionFailure("Cant't create url from url components...") } return transport.fetch(for: url) .decode(type: Model.self, decoder: JSONDecoder()) .map { $0.items } .eraseToAnyPublisher() } } protocol Fetchable: Decodable { associatedtype Response static var apiBase: String { get } var items: [Response] { get set } } protocol Transport { func fetch(for url: URL) -> AnyPublisher<Data, URLError> } extension URLSession: Transport { func fetch(for url: URL) -> AnyPublisher<Data, URLError> { dataTaskPublisher(for: url).map { $0.data }.eraseToAnyPublisher() } }
- Repo
struct Repo: Decodable, Identifiable { var id: Int let owner: Owner let name: String let stargazersCount: Int let description: String? enum CodingKeys: String, CodingKey { case id case owner case name case stargazersCount = "stargazers_count" case description } struct Owner: Decodable { let avatar: URL enum CodingKeys: String, CodingKey { case avatar = "avatar_url" } } } // struct RepoResponse: Decodable { // let items: [Repo] // } struct RepoResponse: Fetchable { var items: [Repo] static var apiBase: String { "/repositories" } }
- LoadData
let repoData: RepoResponse = load("repositories.json") func loadRawData(_ filename: String) -> Data { let data: Data guard let file = Bundle.main.url(forResource: filename, withExtension: nil) else { fatalError("Couldn't find \(filename) in main bundle.") } do { data = try Data(contentsOf: file) } catch { fatalError("Couldn't load \(filename) from main bundle:\n\(error)") } return data }
- 投稿日:2020-02-16T15:45:00+09:00
(at)PublishedがどのようにSwiftUIのViewを更新するのか
(at)PublishedがどのようにSwiftUIのViewを更新するのか
@Published
属性がどのようにSwiftUIのViewを更新するのかについて書いておきます。WWDC19の動画を見るとそもそも
@Published
属性がまだ存在していないのと、その当時はObservableObject
型がBindableObject
型であったり、@ObservedObject
属性が@ObjectBinding
属性だったりするのでややこしいためです。私の結論としては
@Published
あると便利なんだけど、コードを書き換えてるときになぜこのプロパティに@Published
にしたんだろう、という感覚になりやすい。おとなしく手動でやっといたら意図も読みやすくて混乱しなかったのにという気持ちになりやすいからですかね。
@ObservedObject
属性として指定されたObservableObjectはSwiftUIにsubscribeされている
- Viewは
@ObservedObject
指定したObservableObject
のobjectWillChange
をsubscribeしている
- subscribeし発火されるとViewを更新するようにしている
ObservableObject
でobjectWillChange
を使い発火させればいい@Published
はプロパティのwillSetでobjectWillChange
を使い発火させるのを暗黙的に行っているobjectWillChangeとは何か
objectWillChange
はCombineで定義されているObservableObject
を見るとわかります。@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) public protocol ObservableObject : AnyObject { /// The type of publisher that emits before the object has changed. associatedtype ObjectWillChangePublisher : Publisher // 制約の追加 Swift4.1(SE-0157) = ObservableObjectPublisher // デフォルトで利用する型 where Self.ObjectWillChangePublisher.Failure == Never // 条件 /// A publisher that emits before the object has changed. var objectWillChange: Self.ObjectWillChangePublisher { get } }
- プロパティ
objectWillChange
はSelf.ObjectWillChangePublisher
を返す- associatedtypeの
ObjectWillChangePublisher
Publisher
プロトコルを制約として追加している- 言い換えると
ObservableObject
はパブリッシャーを保持している- デフォルトでは
ObservableObjectPublisher
クラスを使う
- さらにエラーは流さない
- おそらくカスタマイズした
Publisher
も使えるObservableObjectPublisherとは何か
Combineで用意された
ObservableObjectPublisher
クラスを確認してみます。/// The default publisher of an `ObservableObject`. @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) final public class ObservableObjectPublisher : Publisher { /// The kind of values published by this publisher. public typealias Output = Void /// The kind of errors this publisher might publish. /// /// Use `Never` if this `Publisher` does not publish errors. public typealias Failure = Never public init() /// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)` /// /// - SeeAlso: `subscribe(_:)` /// - Parameters: /// - subscriber: The subscriber to attach to this `Publisher`. /// once attached it can begin to receive values. final public func receive<S>(subscriber: S) where S : Subscriber, S.Failure == ObservableObjectPublisher.Failure, S.Input == ObservableObjectPublisher.Output final public func send() }
- OutputはVoidなのでイベントは
(Void) -> Void
のクロージャ
.sink { (Void) -> Void in ... }
- SwiftUIのViewとしては更新されたら
body
が実行される
- 使用されるオブジェクトのプロパティは全て読みだされるので
- イベント時のOutputがVoidでも特に気にしない
- どのような場合にカスタマイズする?
- Outputを変更したい場合がある?
- 思いつかないな...
必ずしも
@Published
を使う必要はない
@Published
を使いたいのは次の優先度のとき
- Viewが
Binding
なオブジェクトを取り出してバインディングしたい(読み込みと書き込み)ObservableObject
の中でfilter
やflatMap
したいobjectWillChange.send()
を自動でやりたいRxSwiftのようなUIからストリームを作れる仕組みがないために、
Binding
での値のやり取りが楽なので@Published
を使いたい。本当はそれ一択かもしれない。SwiftUIガイドブック
BOOTHで電子書籍として販売しています。
https://booth.pm/ja/items/1829015
読者のターゲットは次のような人を想定しています
- SwiftUIのレンダリングシステムが自動で更新されるタイミングを知らない
- 更新のきっかけになる
$ObservedObject
の@Published
にもやもやしている- SwiftUIのViewが自動で更新されるタイミングやその範囲にもやもやしている
@State
や@Binding
、@ObservedObject
の違いがよくわからない
@Environment
と@EnvironmentObject
の違いもまだ知らない- $オペレータを雰囲気で使って雰囲気で修正している
- コンパイルエラーになるたびに修正している
- propertyWrapperとdynamicMemberLookupを理解して使っていない
SwiftUIでpropertyWrapperとdynamicMemberLookupがあやふやだなーという気持ちになったままコードを書くことも多いのではないかと思ってます。
SwiftUI触ってる人に質問ですけどpropertyWrapperとdynamicMemberLookupについて理解してSwiftUIを触ってる?それともあやふやなままコンパイラに都度注意されてる?
— imajô (@yimajo) February 9, 2020
- 投稿日:2020-02-16T13:48:05+09:00
【Swift5】カラー指定する方法のチートシート永久保存版
この記事を書いた理由
Swiftでのカラー指定の方法は、たくさんあります。
毎回毎回、やりたいカラー指定のやり方を調べるのがめんどくさかったため、自分でカラー指定のチートシートを作成しました。環境
ツール version Swift version 5.1.3 Xcode Version 11.3 (11C29) チートシート
- storyboardでカラー指定する方法
- UIColorのプロパティでカラー指定する方法
- UIColorでRGB(CGFloat型)でカラー指定する方法
- UIColorを無理やりRGBでカラー指定する方法
- UIColorを16進数のカラーコードでカラー指定する方法
- UIColorをRGBでカラー指定する方法
- カラーリテラルを使ってカラー指定する方法
- Assets.xcassetsで色を管理し、カラー指定する方法
他のチートシート
- 投稿日:2020-02-16T12:13:28+09:00
SwiftUI VStackこういうことだったのか劇場
背景
SwiftUIにて画面作成するときにほぼ間違いなくお世話になる、VStack、HStackについて、その公式ドキュメントのリファレンスと実装に差異があり戸惑いました。そのときの疑問とそれをどのように整理して解消したのかを記録したものです。
対象とする読者
Swift初心者
SwiftUI初心者環境
私がこの記事を書いている際に利用しているのは次の環境です。
Xcode 11.3.1
Swift 5.1はじめに
VStack、HStackは、SwiftUIで画面レイアウトを作るときにほぼ間違いなくお世話になるもので、ビュー(ここでいうビューは、ボタンやテキスト等のUIコントロール部品や、それらをグループ化した子ビューなど広義でのUIコントロール全般を指すものとご理解ください)をレイアウトとして配置するためのコンテナーとして利用され、それ自体もビューとなります。
ビューを縦(垂直)方向に並べるときに利用するのが、VStack
ビューを横(水平)方向に並べるときに利用するのが、HStack
ビューを奥行き方向に並べるときに利用するのが、ZStack入れ子でこれらを使い分けることで、様々なレイアウトにカスタマイズして画面を構成していきます。
今回は、このうちのVStackを取り上げます。
VStack
イニシャライザの呼び出し方
イニシャライザとは、インスタンスの初期化用メソッドのこと。
公式ドキュメントの記載は次のとおりです。
公式ドキュメント(VStack)
https://developer.apple.com/documentation/swiftui/vstack// Creating a Stack init (alignment: HorizontalAlignment, spacing: CGFloat?, @ViewBuilder content: () -> Content)VStackイニシャライザのパラメータは3つあります。
alignment
水平方向の配置位置をHorizontalAlignment構造体のタイププロパティで指定。.leadeing
(左寄せ),.center
(中央寄せ),.trailing
(右寄せ)等の指定が可能。デフォルトは.center
spacing
子ビュー同士の間隔を調整するための余白を指定します。デフォルトはnil
でシステムデフォルトの余白が付与されます。content
このスタックに並べるビュー(子ビュー、ボタン、テキストなど)をまとめたものを返すクロージャを指定します。デフォルトのあるパラメータは省略可能です。一番最後の
content
だけは必ず指定が必要です。実際の使い方
VStack(alignment: .leading, spacing: 10) { Text("Hello World!") Text("Here We Go!") }もし私のようなSwift初心者は、ここで少し混乱します。
- 疑問1 イニシャライザの定義だと引数が3つあるけど、そのうちの最後のcontentが欠落している?
- 疑問2 その代わりに、{}内にVStack内に表示させたいビュー(Text2つ)を記述している?
- 疑問3 そもそも{}内にビュー(Text2つ)を改行して列挙してだけのようだけど、こういう書き方していいの?
では、これらの疑問を見ていきます。
疑問1と疑問2について
swiftでは「トレーリングクロージャー式」という記述をすることが可能です。
トレーリングは後方を指し、クロージャー式は{}で囲んだ式のこと。トレーリングクロージャーというのは、以下のようなSwift独自の構文です。
関数の最後の引数としてクロージャー式を渡す必要があり、クロージャー式が長い場合に、それを関数の引数として記載せずに、関数呼び出しの括弧の後(末尾)にクロージャー式{ }として記述できる
つまり、さきほどの実装例はトレーリングクロージャー式で記述されていたということです。それを利用しなかった場合は次のよう書き換えることができます。
// トレーリングクロージャー式を使わないで書くと・・・ VStack(alignment: .leading, spacing: 10, content: { Text("Hello World!") Text("Here We Go!") } )この例では、たしかにイニシャライザの宣言になんとなく寄ってきました。クロージャーの中身が数行なので、読みにくいということもありません。
ですが、クロージャーに記述する行数が増えれば増えるほど、読みずらくなるのは容易に想像できますので、基本はトレーリングクロージャー式を利用して、重たい記述は引数とは別に記述すべきなのですね。とても合理的な考え方だと思います。
疑問3について
疑問1,疑問2 についてはコードの書き方として便利な式が用意されていることを知りました。しかし、疑問3の「そもそも{}内にビュー(Text2つ)を改行して列挙してだけのようだけど、こういう書き方していいの?」については、まだ解決していません。
ここで、改めてVStackのイニシャライザを見てみると、content引数には、
@ViewBuilder
属性が付与されていることがわかります。この属性はSwift5.1の新機能である「ファンクションビルダー」が利用されています。
この属性がついたクロージャー内の各行は、暗黙的に1行が1引数となって、ViewBuilder
のbuildBlock
メソッドに渡されます(暗黙的に内部で変換されます)。つまり、クロージャー内にTextを2行記述しましたが、実際は内部では以下のように2つの引数を受け取るbuildBlockメソッドに渡され、View型のインスタンスを返却してもらっているのです。
// ViewBuilderへの変換がされた後の実装はこんな感じ //(こちらの実装でも、もちろん表示されます) VStack(alignment: .leading, spacing: 10, content: ViewBuilder.buildBlock( Text("Hello World!"), Text("Here We Go!") )ここまで記述すると、ようやくイニシャライザの定義と完全に理論的な一致が確認でき、安心できます。
なお、ViewBuilderでは、VStack、HStackなどクロージャー内部に最大10行のViewまでを引数として置き換えてくれるbuildBlockメソッドが事前に用意されています。VStackに10行分のTextを記述しても正常に画面表示できますが、11行以上記述するとビルドエラーが発生します。時間がある方はぜひ実際に試してみてください。
さいごに
新しい関数(イニシャライザ)を見て、リファレンス(公式ドキュメント)を参照するのは開発者として基本的な行動です。ですが、実際のコードとリファレンスに乖離があると、混乱したり正しい理解の妨げになります。
私も今回のこの記事を自分で整理してアウトプットすることで、頭の中を整理できました。私のようなSwift初心者の方に、この記事が少しでも役立てば幸いです。
- 投稿日:2020-02-16T11:04:08+09:00
Swift 基礎知識 1
はじめに
現在、私はRubyを中心に学んでおります。
学習を進める中、他言語にも興味が沸いた為、Swiftに関して用語等を整理していたいと思いました。
もうすでにご存知の方、省略の仕方等ご存知でしたら、ご教授願います。Swiftとは
ネイティブアプリを開発するためのプログラミング言語
- ネイティブアプリ : あるプラットフォームで直接動作するアプリのこと(スマホとかですね)
対象OS
- MacOSとiOS
- 上述の通り、iPhoneやiPadなどの端末で使えるアプリ、もしくはMacパソコン向けのアプリを開発することができる。
今までのネイティブアプリ開発は?
- これまではObjective-Cが使われていた。(現在も使える様です)
- RubyやPHPなど最新の設計思想を盛り込んでいて、スクリプト言語のように簡単にアプリを開発することができる。
特徴
- SwiftはXcodeを使って開発をする。
- XcodeはApple IDがあれば無料でダウンロードすることができる。
勝手なイメージですが、敷居が高くなく、すぐに入り込めそうな感じがします。
さいごに
日々勉強中ですので、随時更新します。
皆様の復習にご活用頂けますと幸いです。
- 投稿日:2020-02-16T08:39:11+09:00
loadBodyTrackedAsync が使えない際の対処法
- 投稿日:2020-02-16T02:12:12+09:00
無料でPHP使ってWebAPIをつくってみた
はじめに
以前つくった文字列→塩基配列の相互変換ツールをつくってみた(アプリ版)のWeb版(PHP)がこれにより不要になってしまいました
せっかく作ったPHP版も何かに使えないかと思いAPI化してみました
*注意 httpです
成果物
ドキュメント
BluePrintでAPIドキュメントみたいなのつくってみましたので詳細はこれを見てください
http://adventam10.php.xdomain.jp/dna/api/
ソース
下記の dna-converter.php です。
https://github.com/adventam10/DNAConverter-web/tree/master/api
API化
PHPで簡単なWebAPIを実装してみるを参考に実装!!
こんな感じで実装して下記のように XFREE にアップ!!!
<?php header('Content-Type: text/html; charset=UTF-8'); $json_string = file_get_contents('php://input'); $json = json_decode($json_string, true); $resultCode = checkResultCode($json); if($resultCode === 0) { $arr["resultCode"] = $resultCode; $arr["convertedText"] = convert($json["text"], $json["mode"]); } else { $arr["resultCode"] = $resultCode; } print json_encode($arr, JSON_PRETTY_PRINT);swift で確認してみる
let urlString = "http://adventam10.php.xdomain.jp/dna/api/dna-converter.php" var request = URLRequest(url: URL(string: urlString)!) request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" request.httpBody = try? JSONEncoder().encode(Request(mode: 0, text: "あいうえお")) let task = URLSession.shared.dataTask(with: request) { (data, _, error) in let response = try? JSONDecoder().decode(Response.self, from: data!) print(response) } task.resume() struct Request: Codable { let mode: Int let text: String } struct Response: Codable { let resultCode: Int let convertedText: String }ちゃんと以下のように取得できていました
Optional(Test.Response(resultCode: 0, convertedText: "GCAGCAATCAACGCAGCAATCATAGCAGCAATCATCGCAGCAATCACAGCAGCAATCACC"))ハマったとこ
POSTMAN を使って確認していたのですが下記のようにリクエストをしていると mode が数値ではなく文字列になってしまいハマりました。
その時のPHPの実装が下記
$resultCode = checkResultCode($_POST); function checkResultCode($param) { if (isset($param["mode"])) { $mode = $param["mode"]; if ($mode === 0 || $mode === 1) { // 文字列なのでここでアウト if (!empty($param['text'])) { $text = $param['text']; if ($mode === 0) { return 0; } else { if (isInvalidDNA($text)) { return 4; } else { return 0; } } } else { return 3; } } else { return 2; } } else { return 1; } }POSTMAN で下記のように JSON を設定すると数値が送れるらしいと聞いたので修正。
が、しかし、PHP が
$_POST
だったので値を受け取れずどうやら
$_POST
では JSON を受け取れない模様。下記のように PHP を修正。
$json_string = file_get_contents('php://input'); $json = json_decode($json_string, true); // 第2引数にtrueを設定しないとダメらしい $resultCode = checkResultCode($json); function checkResultCode($param) { if (isset($param["mode"])) { $mode = $param["mode"]; if ($mode === 0 || $mode === 1) { if (!empty($param['text'])) { $text = $param['text']; if ($mode === 0) { return 0; } else { if (isInvalidDNA($text)) { return 4; } else { return 0; } } } else { return 3; } } else { return 2; } } else { return 1; } }無事数値を取得できました
ドキュメント作成
せっかくなんで API Blueprint でドキュメントを作成してみました。
参考:API BlueprintでWeb APIのドキュメントを生成する
ハマったとこ
node.js を https://nodejs.org/ja/ ここから取得して入れていたので
npm install -g aglio
がパーミッションエラーになってしまいました下記参考に設定を行い無事インストールできました
npmでpermission deniedになった時の対処法[mac]
ドキュメントの書き方あんまわからなかったので下記参考にしながらそれっぽいものを書いてみました。
API Blueprint を使って Web API の仕様書を書こう
さいごに
とりあえずこれで PHP で作成したものも無駄にならずにすみました
これでどこからでもDNA変換ができます!!!
(swift の POST がライブラリ使わずにやるのやり方忘れてて地味に苦労しました)