Iganinのブログ

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

【SwiftUI】要素数の少ない場合の表示も考慮したチャットライクな画面の実装

環境

以下の環境で実施しています。

要件定義

いわゆるチャットのUIにおいては、最新の要素が画面下部に追加されていき、画面の初期表示時は画面最下部を基準に表示されているという要件が主流かと思います。

また、その一方で要素数が少なく画面全体を占めないような場合は要素が上の方に詰まっている以下のような画面を表示するのが要件として一般的ではないでしょうか。

つまり、以下の要件が一般的なチャット画面に期待されるものかと思います。

  • 要件① 最新のデータは画面下部に追加されていき、画面上部が過去のデータとなる。
  • 要件② 要素数が少ない時は画面上部に要素が詰まっている。

要件①を満たす実装

要件①を満たすための実装を考えます。 ScrollViewやListをそのまま使用すると、scrollや画面表示の都合上実現が難しいため工夫が必要です。 さまざまな実現方法があるかと思いますが、ScrollView、Listを反転し、中身の要素を再度反転させ整合性をとる実装が多いのではないでしょうか。

struct SampleScreen: View {
    @State private var list: [Sample] = []
    
    var body: some View {
        List {
            Group {
                ForEach(list) { sample in
                    cell(sample: sample)
                }
            }
            .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
        }
        .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
    }
}

問題

先ほどの実装で要件①は満たすことができますが、実は要件②は満たせていません。 Listを180度回転させている影響で、要素が画面下部によってしまい、要素数が少ない場合に要素が下に寄ってしまいます。

解消方法

Viewを反転させ要件①を満たす方法のみでは要件②が満たせないことをお伝えしました。 ここで少々トリッキーではありますが、最近見つけた方法を共有します。

考え方は以下です。

  1. Scroll領域の下部をSpacerなどで埋める
  2. 1を実現するために、Scroll領域のContentsの高さを測り反映させる

具体的な実装を見ていきましょう。

解消方法の実装

具体的な実装は下記のようになります。 ScrollViewの内部に内部要素を上に詰めるためのSpacerを含めています。 Spacerの高さとScrollViewに含まれる他の要素の高さの合計が画面全体の高さを越えるようにする必要があります。

ここで他の要素の高さを取得するのがそのままでは難しいため、PreferenceKeyと@Stateで定義したheightを使用しSpacerに伝えるようにしています。 こちらの実装で先ほどの要件②も満たすことができ、要件①、要件②を満たすチャットライクな画面を実現できます。

struct HeightKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value += nextValue()
    }
}

struct SampleView: View {
    @State private var list: [Sample] = []
    @State var height: CGFloat = 0

    var body: some View {
        GeometryReader { scrollViewProxy in
            ScrollView {
                // 要素数が少ない場合に要素を上詰めするために画面下部にスペースを確保する
                // 他の要素で画面全体のスペースが埋まる場合は不要なので、高さを0とする
                Spacer()
                    .frame(height: max(scrollViewProxy.size.height - height, 0))
                LazyVStack(alignment: .leading, spacing: 0) {
                    ForEach(viewModel.list) { sample in
                        cell(sample: sample)
                            .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
                    }
                }
                .overlay {
                    GeometryReader { contentsProxy in
                        // PreferenceKeyを使用しコンテンツの高さを伝える
                        Color.clear.preference(
                            key: HeightKey.self,
                            value: contentsProxy.size.height
                        )
                    }
                }
            }
            .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
            .onPreferenceChange(HeightKey.self) { newHeight in
                // コンテンツの高さの変化を検知し、\@Stateで保持している
                // heightに伝えViewに反映させる
                height = newHeight
            }
        }
    }
}

終わりに

今回ご紹介した実装を書きGistにアップロードしていますので、ご興味ある方はお手元で動かしてみてください。

https://gist.github.com/HironobuIga/1abd6e38fddc5d06f1e7be5a49208704

@tobi462さん@_natpenguinさんのお力添えのおかげで今回の実装にたどり着くことができました。 この場を借りまして御礼申し上げます。

【iOS】PlaygroundでSwiftUIのViewを描画する

はじめに

SwiftUIで簡単なView構成を試したいときにわざわざProjectを作るのもめんどくさいなという時がありました。 Playgroundを使用してViewの画面を作成し、表示や動作を確認する方法がわかりましたのでメモがてら記載します。

環境設定

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

内容

Playground Supportをimportし PlaygroundPageを使用することでPlayground上で画面を描画することができます。 PlaygroundPage.current.setLiveView(ContentView())としてるのが設定箇所です。 UIHostingViewControllerを使用して、PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())とすることでも設定可能です。

コード例を下記に記載します。

import Combine
import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                Text("Sample1")
                Text("Sample2")
                Text("Sample3")
            }
        }.navigationBarTitle("Sample Page")
    }
}

// ここで画面描画のための設定を行なっている
PlaygroundPage.current.setLiveView(ContentView())

画面表示は下記となります。コードを実行することで動作確認等も行うことができます。 f:id:Iganin:20200518065527p:plain

まとめ

Playgroundは主に簡単なロジックの挙動確認やSwiftの仕様確認に使用していましたが、簡単な画面の作成に使用するのも良さそうです。

参考

【iOS】UIViewをUIImageに変換する

はじめに

UIViewをUIImageに変換するというよくあるやつです。 今だとこのやり方が良いのではないかというのが見つかったのでメモがてら記載します。

環境設定

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

内容

いかに作成したextensionメソッドを記載します。 UIGraphicsImageRendererを使用することでシンプルに書くことができます。

public extension UIView {
    func convertToImage() -> UIImage {
       let imageRenderer = UIGraphicsImageRenderer.init(size: bounds.size)
        return imageRenderer.image { context in
            layer.render(in: context.cgContext)
        }
    }
}

従来通りのよくあるやり方は下記です。 UIGraphicsBeginImageContextWithOptionsを使用することでUIImageを作成しています。 UIGraphicsBeginImageContextWithOptionsscaleに0.0以外を入力したり、 UIGraphicsBeginImageContextを使用したりすると画像がぼやけたりするので注意が必要です。 また、 UIGraphicsGetImageFromCurrentImageContext()の返却値がOptionalのため安全に書こうとするとメソッドの返却値が UIImage?となります。

public extension UIView {
    func convertToImage() -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0.0)
        guard let context = UIGraphicsGetCurrentContext() { return nil }
        layer.render(in: context)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return image
}

まとめ

UIGraphicsImageRendererはiOS10で導入されたclassです。contextの制御やscaleの管理などをせずに描画を行うことができます。 OSのサポートバージョンが10.0以上でしたら是非使用してみてください。

参考

【iOS】UITextViewでタップ遷移可能なバナーを表示する方法

はじめに

UITextViewにHtmlの記述を反映させたいことがあります。その場合は下記のようなコードを書くことで、<a>タグでのリンクの表示などが反映可能です。 Stringのextensionメソッドとして作成しておくと便利です。

extension String {
    func convertToHtml() -> NSAttributedString {
        // Stringをdata化
        guard let data = self.data(using: .utf8) else { return NSAttributedString(string: self) }
        
        // dataからNSAttributedStringを生成
        // この際にdocument typeを.htmlとすることでhtmlのタグを機能させることができます
        guard let attributedString = try? NSAttributedString(
            data: data,
            options: [.documentType: NSAttributedString.DocumentType.html,
                      .characterEncoding: String.Encoding.utf8.rawValue],
            documentAttributes: nil) else { return NSAttributedString(string: self) }
        return attributedString
    }
}

しかし、バナーのような画像を含めた場合、そのままでは画面サイズにうまく合わせることができません。例えば<img src="${banner image url}" alt="サンプル" width=100%> とした場合に表示するUITextViewの横幅いっぱいにバナー画像が広がって欲しいですが、そうはいきません。

これは、画像部分がUIImageやUIImageViewではなく、NSTextAttachmentによって作成されているためです。 本稿ではそのようなHtmlに含んだバナーを適正なサイズにして表示する方法を記載します。

環境設定

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

内容

いかに作成したextensionメソッドを記載します。

extension NSAttributedString {

    /// htmlに含まれる画像部分をwidthの幅まで縦横比を保ちながら拡大・圧縮します
    func resizeHtmlImages(to width: CGFloat) -> NSAttributedString {
        
        let mutableString = NSMutableAttributedString(attributedString: self)
        
        // NSMutableAttributedStringのattributeからNSTextAttachmentを探します
        // Html内に含まれる<img>タグはNSTextAttachmentとして扱われいます
        // NSRange(location: 0, length: mutableString.length)で文字列全体を指定しています
        mutableString.enumerateAttribute(.attachment, in: NSRange(location: 0, length: mutableString.length), options: []) { (value, range, _) in
            
            if let attachment = value as? NSTextAttachment {
                
                // 見つかったNSTextAttachmentから画像を取得します
                guard let image = attachment.image ?? attachment.image(
                    forBounds: attachment.bounds, textContainer: nil, characterIndex: range.location) else { return }
                
                // 画像を比率を保ったまま拡大・縮小します
                let ratio = width / image.size.width
                guard let resizedImage = image.resize(ratio: ratio) else { return }
                let resizedAttachment = NSTextAttachment()
                resizedAttachment.image = resizedImage

                // ここが本稿の肝です。
                // 画像タップ時のイベントハンドリングなどを除去しないため、画像のattributeのみ除去し、リサイズ後のattributeに入れ替えます。
                mutableString.removeAttribute(.attachment, range: range)
                mutableString.addAttribute(.attachment, value: resizedAttachment, range: range)
            }
        }
        
        return mutableString
    }

}

ここでの肝はMutableNSAttributedStringに含まれるNSTextAttachmentの画像サイズを変換し、Attributeを入れ替える際に下記のようにしていないことです。

let attributedString = NSAttributedString(attachment: resizedAttachment)
mutableString.replaceCharacters(in: range, with: attributedString)

検索すると上記のようにAttributeを入れ替える記載が出てきますが、このようにすると画像はリサイズされて表示されますが、 バナータップ時の画面遷移等を実現することができません。これは実際に含まれるAttributeを見ることで理解できます。

例えば次のようなhtmlタグを表示したいとします。 <a href="https://google.com"><img src="${banner image url}" alt="サンプル"></a>

NSAttriburtedStringのattributeをみるとわかりますが、下記のようにattachmentだけでなく NSLink等も含まれています。

{
    NSAttachment = "<NSTextAttachment: 0x600002bb3330> \"${image}"";
    NSColor = "kCGColorSpaceModelRGB 0 0 0.933333 1 ";
    NSFont = "<UICTFont: 0x7fa05cfeb2b0> font-family: \".SFUIText\"; font-weight: normal; font-style: normal; font-size: 18.00pt";
    NSKern = 0;
    NSLink = "https://google.com/";
    NSParagraphStyle = "Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 15/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n), DefaultTabInterval 36, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0";
    NSStrokeColor = "kCGColorSpaceModelRGB 0 0 0.933333 1 ";
    NSStrokeWidth = 0;
}

replaceCharactersを使用すると、該当の箇所のattributeがNSAttachmentのみになってしまい、NSLinkがなくなってしまうことで押下時の遷移等が再現できなくなります。

{
    NSAttachment = "<NSTextAttachment: 0x600000ec00e0>";
}

該当の箇所のattributeの入れ替えではなく、リサイズ前のattachmentを削除し、新しいattachmentを該当箇所のattributeとして追加することで上記の状況を回避しています。

mutableString.removeAttribute(.attachment, range: range)
mutableString.addAttribute(.attachment, value: resizedAttachment, range: range)

まとめ

NSAttributedStringを用いてHtmlを表示するとサーバーからの返却値を用いて幅広い表現が可能です。ぜひ試してみてください。

【Swift】Swift5.1からOptionalのEnumのswitch文をより簡単にかけるようになった

はじめに

Swift5.0まで OptionalEnumインスタンスに対してswitch文を使用する際は下記のように.some(T).noneで記述する必要がありました。

enum Frequency {
    case daily
    case weekly
    case monthly
    case yearly
}

let frequency: Frequency? = .daily
switch frequency {
case .some(.daily): print("daily")
case .some(.weekly): print("weekly")
case .some(.monthly): print("monthly")
case .some(.yearly): print("yearly")
case .none: print("nil")
}

これは Swift では Optional が下記のような enumで表現されていることに起因します。

enum Optional<T> {
    case some(T)
    case none
}

このswitch文での分岐に関してSwift5.1で変更があったため記載します。

環境設定

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

  • Swift5.1

内容

Swift 5.1から Optionalenumのプロパティをswitch文で分岐させる際に、 .some(T) のように記載する必要がなくなりました。 具体的には「はじめに」に記載したコードをSwift5.1では下記のように記述することができます。

enum Frequency {
    case daily
    case weekly
    case monthly
    case yearly
}

let frequency: Frequency? = .daily
switch frequency {
case .daily: print("daily")
case .weekly: print("weekly")
case .monthly: print("monthly")
case .yearly: print("yearly")
case .none: print("nil")
}

まとめ

細かな変更ですが .someの記載が不要になったことで . を打つだけで分岐先の候補が一覧で補完されるようになり、書き味がより良くなったと思います。

【Swift】StructやClassスコープからSelfでstatic memberへのアクセスが可能になった

はじめに

Swiftではstructやenumおよびclass内からstaticなメンバーにアクセスする際には以下のようにClass名.staticMemberかtype(of: self).staticMemberとする方法がありました。

struct Sample  {
    static var hoge: Int = 0
    
    func sampleFunction() {
        // クラス名.staticMember
        let a = Sample.hoge
        // type(of: self).staticMember
        let b = type(of: self).hoge
    }
}

Swift5.1でこちらのアクセス方法に変更があったため記載します。

環境設定

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

  • Swift5.1

内容

structやclass, enum内のスコープにおいて、 Self.staticMemberでstaticなプロパティやメソッドにアクセスできるようになりました。 具体的には下記のようになります。

struct Sample {
    static var hoge: Int = 0
    static func fuga() {}
    
    func sampleMethod() {
        // こちらは従来からできました
        let sampleA = Sample.hoge
        let sampleB = type(of: self).hoge

        // swift5.1からこのアクセス方法が可能になっています
        let sampleC = Self.hoge
        
        Sample.fuga()
        type(of: self).fuga()

        // メソッドにも同様にアクセス可能です
        Self.fuga()
    }
}

class ClassSample {
    static var hoge: Int = 0
    class func fuga() {}
    
    func sampleMethod() {
        // classでも同様にアクセス可能です
        let sampleA = Self.hoge
        Self.fuga()
    }
}

まとめ

プロポーザルにも記載がありますが、 インスタンスのselfに対応する形でメソッドやプロパティにアクセスすることができ、理解しやすくなったのではないかなと感じています。参考に元のプロポーザルへのリンクを記載していますので是非ご確認ください。

参考

【Swift】Swift5.1からstructのイニシャライザの自動生成でプロパティのデフォルト値が考慮されるようになった

はじめに

Swiftで使用されるstructは定義したプロパティにしたがってイニシャライザメソッドが自動生成されます。 例えば下記のようにUserを定義した場合に自動でイニシャライザが生成されます。

struct User {
    var id: Int
    var name: String
}

// init(id: Int, name: String)が自動生成されている
let user = User(id: 0, name: "Tanaka")

この自動生成処理に関して、Swift5.1で変更があったため記載します。

環境設定

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

  • Swift5.1

Swift5.1での変更点

Swift5.1以前では以下のようにstructのプロパティにデフォルトの値を設定していた場合でも、デフォルトの値を考慮したイニシャライザは自動生成されませんでした。

struct User {
    var id: Int
    var name: String = "unknown"
}

// init(id: Int, name: String = "unknown")は自動生成されず、
// init(id: Int, name: String)しか生成されていないため、
// Swift5.1以前では以下の初期化はコンパイルエラーになる
let userA = User(id: 1)

そのため今までであれば、該当のイニシャライザが欲しければ自分で以下のように定義するしかありませんでした。(※小ネタですが、structのextensionにイニシャライザを書くことで、自動生成されるイニシャライザも使用できます。struct定義内にイニシャライザを書くと自動生成が行われません)

extension User {
    init(id: Int) {
        self.id = id
    }
}

この点に関してSwift5.1で変更があり、デフォルト値が設定されているプロパティを考慮したイニシャライザが生成されるようになりました。

struct User {
    var id: Int
    var name: String = "unknown"
}

// init(id: Int, name: String = "unknown")が自動生成されているため、
// 以下の初期化処理がどちらもコンパイルエラーにならずに成功する
let userA = User(id: 0)
let userB = User(id: 1, name: "Tanaka")

まとめ

structがより便利に使いやすくなりました。参考にプロポーザルと対応するPRのリンクを記載していますので、興味がある方はぜひ見てみてください。

参考

【iOS】Push通知に必要なDeviceTokenの文字列取得に関して

はじめに

サーバーからのPush通知を実行するためには、サーバーにDeviceTokenの文字列を保持し、そのDeviceTokenを指定してAPNs(Apple Push Notification Service)に通信を行う必要があります。DeviceTokenはregisterForRemoteNotifications()の実行によって、application(_:didRegisterForRemoteNotificationsWithDeviceToken:)メソッドを通して通知されます。

しかし、通知されるDeviceTokenはData型であるため、Serverに送信するためには多くの場合、ここからString型に変換する必要があります。本記事では、変換に関してiOS13以降でのdescriptionメソッドに関する変更も交え記載します。

環境設定

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

  • iOS 13.1.2
  • Xcode11.1.0

iOS13以降での変更点

まずiOS13以降でdescriptionメソッドの返却値が変更になった点に関して記載します。 iOS13以前では(deviceToken as NSData).descriptionにて<1xxxxxx5 55xxxxca xxxxx572 00c3xxxx xxxxxxx5 92xxxx90 xxxxxxx7d bxxxxxxa>という文字列が取得できていました。サービスによってはここから以下のメソッドで<>と半角スペースを取り除いて送ったり、そのままServerに送信していたりしていたかと思います。

// <>と半角スペースを取り除く場合のメソッド例
let token = ((deviceToken as NSData).description.trimmingCharacters(in: CharacterSet(charactersIn: "<>")) as NSString).replacingOccurrences(of: " ", with: "")

しかし、iOS13以降でNSDataのdescriptionメソッドの返却値が下記のように変更になりました。{length=32,bytes=0x3dd8xxxxxxxx0be290cxxxxxxxxxx2e0...7xxxxxxxxxxxx0a}形式が大きく変わっていますし、文字列部分に...という省略が加わったことにより、deviceTokenの取得ができなくなりました。

AppStoreに出ているアプリでも本事象が原因と思われるバグ修正のリリースがいくつか見られます。

対応方法

純粋に文字列部分のみ取得したい場合は、@mono0926さんの以下のメソッドがシンプルで良いかと思います。12xxxx6a5xxxxxxxcad80xxxxxxxxxx3b93exxxxxx9xxxxxxx32xxxxxxb83967beこちらのような文字列が取得できます。

let token = deviceToken.map { String(format: "%.2hhx", $0) }.joined()

ただ、 NSDataのdescriptionメソッドの返却値を今までそのまま送っており、諸般の事情で<>や半角スペースを含んだ状態でDeviceTokenの文字列を送らなければならない場合もあるかと思います。その場合は、例えば以下のようにしてNSDataのdescriptionメソッドと同様の文字列を取得できます。

元の文字列を参考にDataを4bytes区切りで文字列に変換しています。最後に<>を付与して返却することでiOS13未満でのNSDataのdescriptionメソッドの返却値を再現しています。配列生成時にcapacityを考慮するとより処理効率が上がりそうですが、実行速度はそれほど問題にはならないと判断しこのままにしています。String(deviceToken: deviceToken)のように使用して文字列を取得できます。

extension String {
    /// DeviceTokenをDataから生成する初期化メソッドです。
    /// RemoteNotificationの実行に必要なTokenを生成します。
    ///
    /// - Parameter deviceToken: Dataから生成したDeviceToken文字列
    ///   例: <70a7ff15 cxxx3c44 bd0xxx2c f75xxxxb dxxxc23f ff14xxxe 6xxxx3f2 b6xxxxxb>
    init(deviceToken: Data) {
        let groupingUnit: Int = 4 // 4bytesずつ区切って文字列を作成する
        let groupCount: Int = 8 // 4bytes区切りの文字列を8つ作成する
        
        var deviceTokenStrings = [String](repeating: "", count: groupCount)
        deviceToken.enumerated().forEach { deviceTokenStrings[Int($0) / groupingUnit] += String(format: "%.2hhx", $1) }
        self = "<" + deviceTokenStrings.joined(separator: " ") + ">" // 前後を < > で囲う
    }
}

まとめ

Push通知の送信に必要なDeviceTokenの文字列取得に関して、<>や半角スペースを含めない場合の方法と含める場合の方法を記載しました。NSDataのdesctiptionメソッドを使用しており、iOS13以降で対応が必要になった場合はご参考にしていただけますと幸いです。

参考

【iOS】アプリクライアント側での文字入力のValidation機構について(UITextField, UITextViewなど)

はじめに

サービスで使用するEmailアドレスやPasswordなどをアプリから入力する際に、アプリ側でValidationをかけることがあります。 そのような際に行う対応として直感的には以下のようにするかと思います。

extension ViewController: UITextFieldDelegate {
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            let text = textField.text
            
            if text?.isEmpty == true {
                errorLabel.text = "文字を入力してください。"
            } else if text?.contains("@") == false {
                errorLabel.text = "正しいメールアドレスの形式ではありません。"
            }
        }
    }
}

ただ、この方法では以下のような問題が発生します。

  • 同様なバリデーション処理が様々な箇所に分散してしまう。結果として修正漏れなどによる不具合が発生する
  • コードが冗長となり可読性が下がる

本記事では、Compositeパターンを適用することでValidation処理を構造的に作成し、 上記であげた問題に対処する方法を記載します。

環境設定

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

  • Xcode11.0.0
  • Swift 5.1.0

方法

Validator Protocolの作成

Validation用のエラーとValidation結果およびValidatorが準拠するべきProtocolを定義します。 ValidationResultはSwift.Resultを使用してもよかったのですが、Resultに指定した型Tの処理の部分が 冗長になるかと考えこの方法をとりました。

ValidationのErrorはLocalizedErrorに準拠することで errorDescription: String?を実装すると、 error.localizedDescriptionでエラーメッセージを表示させることができます。

Validatorはvalidate対象を引数とし、結果を返却するメソッドを持つProtocolです。

/// Validation結果
///
/// - valid: 有効
/// - invalid: 無効
enum ValidationResult {
    case valid
    case invalid(ValidationError)
}

/// Validation結果のエラーに使用
/// LocalizedErrorを使用することで .localizedDescriptionでエラーメッセージを表示可能
protocol ValidationErrorProtocol: LocalizedError { }

/// 文字列のValidationに関する責務
protocol Validator {
    func validate(_ value: String) -> ValidationResult
}

Validatorを複数持つCompositeValidator Protocolを作成します。 Composite ValidatorはvalidatorsとしてValidatorの配列を持ち、validateメソッド実行時に それぞれのValidatorのvalidateメソッドを順次実行、その返却値を元にcomposite validatorの validate実行結果を返却します。 UITextFieldのtextに対して適応するValidatorは基本的にComposite Validatorを使用します。

/// 文字列のValidationに関する責務。複数のValidatorを保持する
/// validatorsに格納されている順にValidationをかけていき、
/// 該当したエラーを返却する。
protocol CompositeValidator: Validator {
    var validators: [Validator] { get }
    func validate(_ value: String) -> ValidationResult
}

 extension CompositeValidator {
    
    func validate(_ value: String) -> [ValidationResult] {
        return validators.map { $0.validate(value) }
    }

     func validate(_ value: String) -> ValidationResult {
        let results: [ValidationResult] = validate(value)
        
        let errors = results.filter { result -> Bool in
            switch result {
            case .valid: return false
            case .invalid: return true
            }
        }
        return errors.first ?? .valid
    }
    
}

Validatorの実装の具体例

上記で記載したValidatorプロトコル、 Composite Validatorプロトコルを元に実際の実装をみていきます。 今回は試しに名前の入力値に対するValidatorを検討します。以下を仕様とします。

  • 文字入力必須
  • 1文字以上、8文字以下
  • 英語大文字小文字のみ入力可

それぞれのケースのエラーをまずは作成します。

enum ValidationError: ValidationErrorProtocol {

    case empty
    case length(min: Int, max: Int)
    case nameFormat
    
    var errorDescription: String? {
        switch self {
        case .empty: return "文字を入力してください"
        case .length(let min, let max): return "\(min)文字以上\(max)文字以下で入力してください。"
        case .nameFormat: return "英語大文字小文字のみで入力してください。"
        }
    }
}

次にそれぞれのケースのValidatorを作成します。

文字入力必須
struct EmptyValidator: Validator {
    
    func validate(_ value: String) -> ValidationResult {
        if value.isEmpty == true {
            return .invalid(.empty)
        } else {
            return .valid
        }
    }
}
1文字以上、8文字以下
struct LengthValidator: Validator {
    let min: Int
    let max: Int
    
    func validate(_ value: String) -> ValidationResult {
        if value.count >= min && value.count <= max {
            return .valid
        } else {
            return .invalid(.length(min: min, max: max))
        }
    }
}
英語大文字小文字のみ入力可
struct NameFormatValidator: Validator {
    
    let regExpression = "^[a-zA-Z]+$"
    
    func validate(_ value: String) -> ValidationResult {
        let predicate = NSPredicate(format: "SELF MATCHES %@", regExpression)
        let result = predicate.evaluate(with: value)
        
        switch result {
        case true: return .valid
        case false: return .invalid(.nameFormat)
        }
    }
}

最後に今まで作成したValidatorを組み合わせたCompositeValidatorを作成します。 これで名前のTextFieldの入力値を検査するValidatorが作成できました。

struct NameValidator: CompositeValidator {
    var validators: [Validator] = [
        EmptyValidator(),
        LengthValidator(min: 1, max: 8),
        NameFormatValidator()
    ]
}

実際の使い方を見てみます。以下のようになります。 Validationの具体的な実装はNameValidator内部に閉じ込められ、同様の処理が拡散することを防いでいます。 Validationの内容を変えたい場合はNameValidatorを変更するか、より下層のここのValidatorを変更すれば良いため修正漏れも防げます。 また、今後新しいComposite Validatorを作成したい場合は今まで作成したValidatorを有効に利用することもできます。 if else などが連続してかかれることもないためコードの冗長性もずいぶん改善され可読性が上がりました。

extension ViewController: UITextFieldDelegate {
    
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            let nameValidator = NameValidator()
            let result: ValidationResult = nameValidator.validate(textField.text ?? "")
            
            switch result {
            case .valid: break
            case .invalid(let error): print(error.localizedDescription)
            }
        }
    }
}

UITextFieldのExtensionとして定義する

先ほどまでのやり方でかなり改善されましたが、さらに簡略化する方法を考えてみます。

一つの方法としてUITextFieldのExtensionメソッドとしてvalidateを作成する方法があります。 Validateの種別(名前、メールアドレス、パスワード)を定義して、その種別に応じたvalidateを実行するメソッドを作成することで実行時の実装はかなり簡略化できます。

extension UITextField {
    
    // Validateの種類を定義する。
    // name, email, passwordなど。
    // パスワードの確認など比較対象がある場合は、
    //case confirmEmail(origin: String)のように比較対象を入れられるようにすると良い
    // validatorにも比較対象を入れられるようにすることで、そのようなValidationも実装可能
    enum ValidateType {
        case name
    }
    
    // UITextFieldのValidationメソッド
    // Validationの結果をUI上に反映させたい場合は反映先のUILabelを引数として渡すとスッキリする(この実装が綺麗かどうかは要検討)
    @discardableResult
    func validate(type: ValidateType, errorMessageOn label: UILabel? = nil) -> ValidationResult {
        
        let result: ValidationResult
        
        // typeごとに使用するValidatorの初期化やvalidateの実行を行う
        switch type {
        case .name:
            result = NameValidator().validate(text ?? "")
        }
        
        // typeによらずErrorLabelへの反映は共通処理のためここで反映させる
        switch result {
        case .valid: label?.text = nil
        case .invalid(let error): label?.text = error.localizedDescription
        }
        
        // Validate結果を返す
        return result
    }
}

上記のExtensionメソッドを使用することで、使用側の実装は下記のようにかなりシンプルになりました。

extension ViewController: UITextFieldDelegate {
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            textField.validate(type: .name, errorMessageOn: errorLabel)
        }
    }
}

まとめ

ユーザーの文字入力をアプリ側でValidateする際に、それらの仕組みをCompositeパターンを使用して構造的に作成する方法を記載しました。本記事の方法を使用することで、修正漏れが少なく可読性が高い保守しやすいValidationの仕組みを作成することができるかと思います。

参考

【iOS】チュートリアル画面などで使えるUIViewに穴をあける方法

はじめに

チュートリアル画面などで、画面全体を半透明な黒いUIViewでおおい、一部分をハイライトして、なんらかの説明文言を表示するといったことがあるかと思います。 この際に、ハイライトしたい部分にUIImageなどを上から重ねるのも手としてはありますが、その部分について黒いUIViewに穴を開けられると便利です。 本記事では該当部分に穴を開ける方法と開けたい場所を指定する際に便利な方法を記載します。

環境設定

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

  • Xcode10.3.0
  • Swift 5.0.1

穴を開ける方法

UIViewlayerに対してmaskを使用することで円形の穴を開けます。 詳細な説明はコードに記載いたしましたが、大まかな流れとしては、mask用のlayerを作成し、そこに円形のPathを作成、 穴を開けたいUIViewlayermaskにmask用に作成したlayerを指定することで穴をあけるとなります。 ここでPathとして円の代わりに星型等を使用すれば星型の穴をあける等も可能です。

なお、UIViewControllerviewDidAppear(_:)などで穴をあけるlayerを追加したところ穴が問題なくあきましたので、 UIViewにextensionでメソッドを作成すると便利に使用できて良いと思います。 当然UIViewを継承しているUIButtonなどでも同様に使用可能です。

public extension UIView {
    
    func makeHole(at point: CGPoint, radius: CGFloat) {
        
        let maskLayer = CAShapeLayer()

        // fillはPathの内部を指定の色で塗りつぶします
        // そのためには内部かどうかの判定が必要になりますが、fillRuleはこの判定方法です
        // .evenOddはある点Pから任意の方向へ無限遠点に射線を引きPathとの交差回数を数えます
        // Pathとの交差回数が奇数回の場合は内側、偶数回の場合は外側と判定します
        // 詳細は参考文献を参照ください
        maskLayer.fillRule = .evenOdd
        maskLayer.fillColor = UIColor.black.cgColor
        
        // 画面全体にPathを描きます
        let maskPath = UIBezierPath(rect: self.frame)
        maskPath.move(to: point)
        
        // addArcで弧形を描画します
        // centerとradiusを指定し、 0.0 ~ 2πで描画するため円形となります
        maskPath.addArc(withCenter: point, radius: radius, startAngle: 0.0, endAngle: 2.0 * CGFloat.pi, clockwise: true)
        
        // 上記から円の内側から無限遠まで射線を引くと、円のPathと画面全体の外縁のPathとで2回交わるため
        // 外側に、円の外側から無限遠まで射線を引くと、奇数回Pathと交わるため内側になります
        // すなわち画面全体のうち円以外の部分が黒色に塗りつぶされます
        maskLayer.path = maskPath.cgPath
        
        // 自身のlayerのmaskとして上記で作成したmask用のlayerを指定しました
        // maskの黒色の部分と重複している箇所の色が残るため、
        // 結果として円の内側は色がなくなり、円形の穴があくことになります
        self.layer.mask = maskLayer
    }
    
}

穴を開けたい場所を指定する際の方法

穴をあける場所に関しては、既存のいづれかのUIViewの上に開けたい場合はその場所を指定するのが良さそうです。 ただ、 view.frameで指定した場合の座標は親のUIViewからの相対位置となるため、画面全体に広げたUIViewからの位置を指定する場合は正しい値となりません。 そのため、以下のように画面全体のViewに対する相対座標を求めるのがおすすめです。

    // MARK: IBOutlet
    @IBOutlet private weak var targetView: UIView!
    @IBOutlet private weak var parentView: UIView!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        let frame = targetView.frame
        print("frame")
        print(frame)
        
        print("parentView frame")
        print(parentView.frame)
        
        print("converted frame")
        let convertedFrame = parentView.convert(targetView.frame, to: self.view)
        print(convertedFrame)
    }

上記でtargetViewが対象のView、parentViewがtargetViewの親Viewです。 上記の実行結果は下記になります。

frame
(20.0, 20.0, 40.0, 40.0)
parentView frame
(107.0, 348.0, 200.0, 200.0)
converted frame
(127.0, 368.0, 40.0, 40.0)

規定のself.viewからの座標が出力されているのがわかります。 この値を使うことで、画面全体を覆った場合等に適切な箇所に穴をあけることが可能になります。

まとめ

チュートリアルのような画面でUIViewに穴を開けたい際には以下のように行うのがおすすめです。

  • maskLayerをつかって穴を開ける
  • 穴の場所を指定するために基準とするViewからの相対位置を指定する(convertPoint:toView:など)

参考