Iganinのブログ

日頃の開発で学んだ知見を中心に記事を書いています。

【iOS】RawReperesentableを使用してID間の使用ミスを避ける方法

はじめに

アプリを作成しているとクラスやStructの一意性の判別のためにidを良く使用します。 例えばUserを定義した場合、その一意性を決めるためにUser.idを定義します。 多くの場合はidはStringかIntで定義するかと思います。

idが一つだけならば良いのですが、 jobIdやgroupIdなど複数のIDを扱うようになると、 jobIdとgroupIdにString型を使用していた場合、それらのIdの入れ間違いが発生し得ます。 Idは一意性を担保するため、この間違いは大きな障害に繋がりかねません。

本記事ではこのような問題を発生させない方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.2.1
  • Swift 5.0.1

方法

import Foundation

typealias Identifiable = RawRepresentable & Codable & Equatable & Hashable

struct User: Codable {
    let id: ID
    let name: String
    let job: Job
    
    struct ID: Identifiable {
        typealias RawValue = String
        let rawValue: RawValue
    }
}

struct Job: Codable {
    let id: ID
    let name: String
    
    struct ID: Identifiable {
        typealias RawValue = String
        let rawValue: RawValue
    }
}

let userString = """
{
    "id": "11111",
    "name": "Tanaka",
    "job": {
        "id": "01",
        "name": "engineer"
    },
}
"""

let userData = userString.data(using: .utf8)!


let user: User
do {
    user = try JSONDecoder().decode(User.self, from: userData)
    print("User: \(user)")
    print("UserID: \(user.id.rawValue)")
    print("UserJobID: \(user.job.id.rawValue)")
} catch let error {
    fatalError(error.localizedDescription)
}

do {
    let encodedUserData = try JSONEncoder().encode(user)
    let encodedUserString = String(data: encodedUserData, encoding: .utf8)!
    print(encodedUserString)
} catch let error {
    fatalError(error.localizedDescription)
}

上記のようにすることで、 UserのidはUser.ID型、JobのidはJob.ID型となり別々の型となるためIDの間違いがなくなります。

IDをRawReperesentableに準拠させることで、文字列から直接User.ID型にDecodeしたり、User.IDから文字列としてのEncodeが可能になります。 また、ID型をEquatableとHashableに準拠させることで、ID自体の同値判定やDictionaryのkeyとしてID自体を使用することを可能にしています。

IDについては各Struct内に定義するのが良いかと思いますが、 IDとしてStringしか使用しないなどの取り決めがある場合は以下のようにすると定義回数が減らせるのでいいかもしれません。

struct ID<T>: Identifiable {
    let rawValue: String
}

struct User: Codable {
    id: ID<User>
}

またRawRepresentableに準拠する際には以下の書き方もできます。

struct ID: RawRepresentable {
    let rawValue: String
}

RawRepresentableについて

RawRepresentableに関して知識が曖昧だったため学習がてらまとめてみます。

  • rawValueとして指定された型へ、もしくは型から変換できる。
  • Enumの型としてString, Int, 不要小数点型(CGFloat, Floatなど)を指定した場合はCompilerが自動的にRawRepresentableを付与する。
  • OptionSet ProtocolはRawRepresentable Protocolに準拠している。

まとめ

IDの間違いは致命的なバグに繋がり得るため慎重な扱いが必要です。 IDを定義する際の選択肢として本記事の方法がお役に立てば幸いです。

参考

【iOS】表示されているテキストをコピーできるUIを作成する簡単な方法

はじめに

iOSアプリを作成していると文言の表示に通常UILabelを使用します。 ただ、そのままではラベル上の文言のコピーを行うことができず、利便性の面であまり満足できないような場合があります。 以下で文言をコピーできるUIを作成する方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.2
  • Swift 5.0

実装

いくつか方法があるかと思いますが、簡単な方法としてUILabelの代わりにUITextViewを使用する方法があります。 基本的にはUILabelの代わりにUITextViewを設定するだけですが、注意点がいくつかあります。

  • isEditable を falseにする
    • trueのままだとタップで編集できてしまうためそれを防ぎます
  • isScrollEnabled を falseにする
    • このパラメータをfalseにすることで、UILabelのように内部の文言によってUITextViewのサイズが決定するようになります

また、表示する文言の行数を制限する場合下記のように設定します。

textView.textContainer.maximumNumberOfLines = 2;
textView.textContainer.lineBreakMode = .byTruncatingTail

Storyboard上では下記の赤枠の設定を変更します。行数制限についてはcode上で設定を行います。

f:id:Iganin:20190313175528p:plain

実際の画面表示は下記のようになります。該当のText部分をドラッグするとCopy, 検索, Shareの選択肢が表示されます。 ただ、左右のマージンを見ると赤背景の部分と、緑背景の部分でテキスト部分の表示に差があります。 ともに左右10ptのConstraintを親Viewに対してかけていますが、TextViewとUILabelはViewとTextContentとのPaddingなどに差があるためこのような表示の違いが生まれます。

f:id:Iganin:20190407214813p:plain

この部分は、下記の設定によって変更することができます。

  • textContainerInset
  • textContainer.lineFragmentPadding
  • layoutManager.usesFontLeading
        // textView内のInset
        textView.textContainerInset = .zero
        
        // TextView内の左右のPadding
        // defaultの値は5.0
        // contentsに使用できるwidthの計算に使用される
        textView.textContainer.lineFragmentPadding = 0.0
        
        // 通常はleadingがfontに依存して変わる
        // usesFontLeadingをfalseにすることでfontに依存しないようになる
        textView.layoutManager.usesFontLeading = false

ただ、上記の設定もコード上から行う必要があります。そのため、IB上からは設定の状況が確認できません。 そこで下記のように IBDesignableのクラスを作成し、使用することでIB上から確認できるようになります。

import Foundation
import UIKit

@IBDesignable
class UILabelTextView: UITextView {
    
    @IBInspectable
    var numberOfLines: Int = 1 {
        didSet {
            textContainer.maximumNumberOfLines = numberOfLines
        }
    }
    
    // IBInspectableにはEnumであるNSLineBreakModeを使用できなかったためIntを経由しています。
    // より良い方法をご存知の方がいましたら共有いただけますと幸いです。
    @IBInspectable
    var lineBreakModeNumber: Int = 0 {
        didSet {
            textContainer.lineBreakMode = NSLineBreakMode(rawValue: lineBreakModeNumber) ?? .byWordWrapping
        }
    }
    
    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonInit()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        commonInit()
    }
    
    private func commonInit() {
        textContainerInset = .zero
        textContainer.lineFragmentPadding = 0.0
        layoutManager.usesFontLeading = false
    }
}

なお、IBDesignableのクラスを作成する際は、IBを開いた際のビルドスコープを狭めるためにUIパーツをEmbeded Frameworkに分離するのがおすすめです。

まとめ

UILabelのような扱いができるUITextViewを使用して表示文言のコピーができるUIを作成する方法をまとめました。 開発の中で上記要件が出た際はぜひ使用してみてください。

参考

XCodeのコンソールに出力されるCloudFirestoreのindex生成URLがうまく機能しなかった

はじめに

CloudFirestoreを使用してFirestore.firestore()でqueryを生成しデータアクセスをする際に、whereFieldやorderでデータの絞り込みや順番を変更することができます。この際に、指定条件によってはIndexの生成をコンソール経由で下記のように提案されます。

[Firebase/Firestore][I-FST000001] Listen for query at Data failed: The query requires an index. You can create it here: https://console.firebase.google.com/project/{project-name}/database/firestore/indexes?create_index=xxxxxxxxxxxxxxx

通常はyou can create it here:以下のURLの遷移先のFirebase Console上でindexの生成が提案されるのですが、特定のケースでURL先への遷移がうまく行かないことがあったので、事象の原因と解消方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.1
  • Swift4.2
  • FirebaseFirestore 1.0.2

発生事象および条件と解消方法

発生する事象は遷移後の画面がホワイトアウトし、正常に遷移できないというものです。

本事象は複数Googleアカウントを使用している場合に発生する可能性があります。 Index生成アドレスをブラウザに入力し、実際に遷移した後のURLを見るとわかりますが、下記のように/u/0/ がアドレス内に現れます。

https://console.firebase.google.com/u/0/project/{project-name}/database/firestore/indexes?create_index=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

元々のURLに /u/x の指定がないため本事象が発生しているようです。 複数アカウントを使用していると、この部分が本来 /u/1/u/2 などでなければならない場合があり、その場合に遷移に失敗してしまいます。 /u/0/u/{Number} に書き換えれば問題なく遷移できました。({Number}部分は実際に使用しているアカウントのコンソール上から下記の画像のように確認できます)

f:id:Iganin:20190323131906p:plain

Swiftにおける Voidと空Tuple - ()

はじめに

本日、UZUMAKIさん主催の「iOSアプリ設計パターン入門」の勉強会に参加しました。

内容はMVVMアーキテクチャに関してでしたが、議論の中でVoidや()の扱いに関して面白いものがでてきたので備忘もかねて記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.0
  • Swift4.2

Void と 空Tupleについて

Voidは以下の形式で表される「型」です。

public typealias Void = ()

そのため、メソッドの引数等にとることはできません。 その際にはInstanceを入れる必要があります。 多くの場合、このような場合の引数として () を使用すると思います。

let sampleRelay = PublishRelay<Void>()

// このように Voidで型指定されている部分に ( ) を入れる。
sampleRelay.accept(()) 

ただ、 Voidが () の typealiasであることから、以下のように () は型としても機能します。

let a: () = ()

また、 Voidは型であるため以下のようにインスタンス化も可能です。

let a = Void()

したがって、 ()を代入する箇所では Void()の代入でも代替可能です。 ただ、 Void()はできますが、 ()()はできません。 Cannot call value of non-function type '()'というエラーが表示され、コンパイルエラーとなります。

まとめ

以上、取り止めがないですがまとめると下記のようになることがわかりました。

// Void は 型、 ()は型としてもインスタンスとしても機能する

let a: () = ()  // OK
let b = ()  // OK
let c = Void() // OK
let d: Void = () // OK
let e = ()() //  NG
let f: () = ()() //  NG

この辺りの言語仕様は普段あまり意識しませんが、色々触ってみると面白いですね。 個人的にはVoidがインスタンス化可能であることが意外でした。

IBOutletCollectionについて

はじめに

InterfaceBuilderの要素をコードと紐づけるために、IBOutlet、 IBActionが使われます。 ただ、複数の共通要素に対してはIBCollectionを使用すると便利です。 自分用のメモをかねて、IBCollectionに関してまとめます。

環境設定

以下の環境を使用しています。

IB要素の定義

IBOutlet - View要素とコードとの紐付け IBAction - ButtonのAction - Target紐付け IBOutletCollection - 複数View要素とコードとの紐付け

それぞれ、 UINibDeclarationsに以下のように定義されています。

#define IBOutlet
#define IBAction void
#define IBOutletCollection(ClassName)

// IBInspectable, IB_DESIGNABLEも定義されています
#define IBInspectable
#define IB_DESIGNABLE

IB Outlet Collection

IBOutlet, IBActionはStoryboard, Xibから紐づけることがおおいかと思います。  IBOutletCollectionも同様にStoryboard, Xibから紐づける際にIBOutletCollectionを選択して紐づけることが可能です。 またIBOutletCollectionはコード側に記載し、それをStoryboard,Xibとひもづけることもできます。

f:id:Iganin:20190130235856p:plain
IBOutletCollection例

@IBOutlet var buttons: [UIButton]!

IBOutlet, IBActionはweakで紐付けられることが多いですが、 IBOutletCollectionは弱参照では定義できないので注意が必要です。 ※配列による強参照での保持は問題ないのか、という疑問があります。このあたりご存知の方がいらっしゃいましたら共有ください。

格納順

IBOutletCollectionと紐づけた順で格納されていました。 ただ、この辺りはOS versionによって変わる可能性があるので、 tag等で並び替えできるようにしておいた方が安全かもしれません。

まとめ

似たような複数のView要素をIBOutletで接続すると、コードが煩雑になるため、 必要に応じてIBOutletCollectionを使用するとコードがスッキリします。

Codableに準拠したStructでネストされたプロパティをフラットにする際のTips

はじめに

Codableに準拠したStructやClassにおいて、ネストされた値をDecodeの際にフラットにしたいとします。 例えば以下のようなJSONに対して、下の階層の値であるaddress.nameを上位の階層のaddressNameとして扱いたい場合などです。 一般的にはこのような場合は、 nestedContainer を使用し、 init(from decoder: Decoder)を使用しますが、ちょっとしたテクニックを使用するとこの実装が不要となります。 本記事ではその方法についてのTipsをご紹介します。

let jsonData = """
{
"first_name": "Taro",
"last_name": "Tanaka",
"age": "30",
"gender": "male",
"address": {
            "code": 13,
            "name": "Tokyo"
        }
}
""".data(using: .utf8)!

環境設定

以下の環境を使用しています。

通常の方法

通常の方法を以下で記載します。 下の階層のプロパティにアクセスするために下の階層用のCodingKeyを定義します。 それを使用し、 nestedContainer 経由でプロパティの値を決めます。 この方法の場合、CodingKey の定義とinit(from decoder: Decoder) の実装が必要になります。

struct Person: Decodable {
    let firstName: String
    let lastName: String
    let age: String
    let gender: String
    let addressName: String

    private enum CodingKeys: String, CodingKey {
        case firstName = "first_name"
        case lastName = "last_name"
        case age
        case gender
        case address
    }

    // 下の階層のプロパティアクセス用にCodingKeyを定義
    private enum AddressCodingKeys: String, CodingKey {
        case code
        case name
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        firstName = try values.decode(String.self, forKey: .firstName)
        lastName = try values.decode(String.self, forKey: .lastName)
        age = try values.decode(String.self, forKey: .age)
        gender = try values.decode(String.self, forKey: .gender)

        //  下の階層のプロパティにアクセスするために nestedContainerを使用し、Containerを作成
        let addressValue = try values.nestedContainer(keyedBy: AddressCodingKeys.self, forKey: .address)
        // nestedContainer経由で下の階層の値を取得、自身のプロパティにセットします
        addressName = try addressValue.decode(String.self, forKey: .name)
    }
}

本記事での方法

Struct 内部にネストされた値のパース用のstructを定義し、パースされたstructをprivateで保持します。 プロパティから値を取得する場合は、 Computed Property経由でネストされた値にアクセスします。 この方法では通常の方法と異なり、 CodingKeyinit(from decoder: Decoderの実装が不要となります。

struct Person: Decodable {
    let firstName: String
    let lastName: String
    let age: String
    let gender: String
    // Computed Propertyを使用し、privateのプロパティから値を取得し返却する
    var addressName: String {
        return address.name
    }

    // パースにのみ使用するため privateで定義
    private let address: Address
    
    // AddressをパースするためにStruct内限定のStructを定義
    private struct Address: Decodable {
        let code: String
        let name: String
    }

}

まとめ

場合により使い分けるべきだと思いますが、本記事の方法によってstructの実装量をあまり増やさずにネストされた値をフラットにすることが可能になります。