【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を表示するとサーバーからの返却値を用いて幅広い表現が可能です。ぜひ試してみてください。

【iOS】NavigationBarのUIBarButtonItemにバッジを付与する方法

はじめに

アプリを作成する際に通知の数をバッジで表現することがよくあります。たとえば通知タブを作成し、タブにバッジを表示する、UIApplication.shared.badgeNumberに値を設定してアプリアイコンにバッジをつけるといった対応です。

開発を行う中で画面のナビゲーションバー部分にバッジを付与したいと行ったケースが発生することがあります。ただ、UIBarButtonItemには通常バッジはデフォルトでは表示できないためひと工夫が必要です。

本記事ではサードパーティーのライブラリに頼らずUIBarButtonItemにバッジを表示する方法を記載します。

環境設定

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

内容

調査して見つかった方法

外部ライブラリ

UIBarButtonItem-Badge

スターの数も比較的多く、UIButtonにもバッジを付与することができます。 ただiOS11以降では下記のIssueがあがっているようにCustomViewを設定しないとバッジを付与できないため注意が必要です。また、更新日付をみるとかなり長い間メンテナンスされていないことがわかります。

IOS 11.2: BarButtonItem Badge is not working · Issue #37 · mikeMTOL/UIBarButtonItem-Badge · GitHub

PPBadgeView

ドキュメントが中国語なので少々読むのが大変ですが、所々ある英単語から雰囲気は掴めるかと思います。最近もメンテナンスされていることが伺えますので導入するならば良いのではないでしょうか。

コード実装

ios - How to put a badge on customized UIBarButtonItem - Stack Overflow

上記のStackOverflowの投稿が見つかりました。 ViewControllerのライフサイクルの中でUIBarButtonItemにLabelをaddSubViewする、viewをObserveValueして呼び出しのタイミングでLabelの初期化を行うなどの方法が記載されています。

UIBarButtonItem with badge, Swift 4, iOS 9/10/11 · GitHub

上記の実装も参考になりました。 badgeNumberが更新されるたびにLabelのupdateを行なっており、badgeNumber > 0 で labelをaddし更新、 badgeNumber <= 0 で LabelをremoveFromSuperViewしています。

今回の実装

以下が今回採用した実装です。注意点はコードでの生成を前提としているためInterface Builder上では機能しないことです。必要に応じて適宜 required init?(coder: NSCoder), IBDesignable, IBInspectableの実装を行なってください。

本実装の問題意識としては、Labelの初期化を本クラスの初期化時にのみ行うようにしたいという点があります。なお、1桁の数字の表示を前提とした実装のため、2桁以上の表示を行う場合は、Label部分の制約を調整してください。

public class BadgeBarButtonItem: UIBarButtonItem {
    
    struct Constants {
        // 表示するバッジの大きさです。
        // 円形バッジを前提として固定値としていますが、2桁以上でも崩れないように
        // するためにはUILabel内にpaddingを設定する等し、Widthの固定値を外して下さい
        static let labelHeight: CGFloat = 18.0
        static let labelWidth: CGFloat = 18.0
        // バッジ位置です。BarButtonItemの中央からの差分をとっています
        static let defaultOffset: CGPoint = .init(x: 12.0, y: -12.0)
    }
    
    // MARK: - Property
    
    public var badgeNumber: Int = 0 {
        didSet { self.updateBadge() }
    }
    
    private lazy var label: UILabel = {
        let label = UILabel()
        // バッジの背景色です。必要に応じて変数化してください。
        label.backgroundColor = .red
        // 1桁の数字を前提としているため円形としています。
        // こちらも必要に応じて変更してください。
        label.layer.cornerRadius = Constants.labelHeight / 2.0
        label.clipsToBounds = true
        label.isUserInteractionEnabled = false
        label.translatesAutoresizingMaskIntoConstraints = false
        label.textAlignment = .center
        // ラベルの文字色です。必要に応じて変数化してください。
        label.textColor = .white
        label.font = .systemFont(ofSize: 12.0)
        // 前面にでるようにするためにzPosition=1としています。
        label.layer.zPosition = 1
        return label
    }()
    
    // MARK: - Life Cycle
    public init(image: UIImage, target: Any?, action: Selector) {
        super.init()
        let button = UIButton(type: .system)
        button.setImage(image, for: .normal)
        button.addTarget(target, action: action, for: .touchUpInside)
        // labelをaddする対象としてCustomViewを使用しています。
        // そのため、UIButtonをCustomViewとしてSetしています。
        self.customView = button
        setupBadgeLabel()
        updateBadge()
    }
    
    required init?(coder: NSCoder) {
        // Interface Builderでの使用は想定していません。
        fatalError("init(coder:) has not been implemented")
    }
}

// MARK: - Private Function
private extension BadgeBarButtonItem {
    func updateBadge() {
        // badgeNumberが更新される都度 Labelの数字を更新します。
        label.text = "\(badgeNumber)"
        label.isHidden = badgeNumber == 0
    }
    
    func setupBadgeLabel() {
        guard let customView = customView else { return }
        customView.addSubview(label)
        label.widthAnchor.constraint(equalToConstant: Constants.labelWidth).isActive = true
        label.heightAnchor.constraint(equalToConstant: Constants.labelHeight).isActive = true
        label.centerXAnchor.constraint(equalTo: customView.centerXAnchor, constant: Constants.defaultOffset.x).isActive = true
        label.centerYAnchor.constraint(equalTo: customView.centerYAnchor, constant: Constants.defaultOffset.y).isActive = true
    }
}

まとめ

ナビゲーションバーに設定ボタンやお知らせボタンを配置するというケースは割と想定される仕様かと思います。そのような要望が出た際に参考にしていただけると嬉しいです。

参考

【iOS】iOS SDK 13 以上でのビルド時に ダークモード非対応とする際の注意点

はじめに

iOS 13からダークモードが導入されてしばらく経ちました。 iOS SDK 13以上(Xcode 11以上)でビルドする場合、ダークモード非対応とする場合はそれ用の対応が必要です。 対応に漏れが発生しかけたので備忘をかねて記載します。

環境設定

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

内容

基本的にはダークモード対応しない場合は、Info.plistにてUser Interface StyleLigntに設定すれば完了です。ただ、歴史のあるアプリですと View controller-based status bar appearanceNOとなっている場合があります。

f:id:Iganin:20200207010555p:plain

View controller-based status bar appearanceNOの場合はStatus Barの表示が User Interface Styleではなく、 プロジェクト設定のGeneral > Deployment > Status Bar Style に依存することになります。

f:id:Iganin:20200207005835p:plain

この設定がDark Contentとなっていれば良いのですが、 DefaultやLight Contentの場合はStatus Bar が白くなってしまい、下の画像のような表示になってしまいます。

f:id:Iganin:20200207010104p:plain

薄ら白く見えますが、非常に見えづらくなっています。

このような状態は User Interface StyleLight にすると同時に View controller-based status bar appearance を YESに変更することで解消することができます。

まとめ

ダークモード非対応とする際は、 User Interface Style の値を設定するだけではなく、 Status Bar の表示に関する設定も適したものになっているか確認した方が良さそうです。

【iOS】Firebase RemoteConfig で作成する強制ダイアログ機構

はじめに

Firebase Advent Calendar 12日目です。本稿では、Firebaseを使用した強制ダイアログ表示に関して記載します。ここで、強制ダイアログと表記しているのは、いわゆる強制バージョンアップダイアログに代表されるダイアログを表示し、それ以上のユーザーのアプリ使用を防ぐ機能を指しています。

具体的には、アプリケーションの起動時や復帰時にダイアログを表示し、アプリケーションのそれ以上の操作を行わないようにするものです。リリースしたアプリに致命的な不具合があった場合にそのバージョンを使用するユーザーを限りなく少なくするために強制バージョンアップダイアログを表示したり、サーバー側の何らかの不具合によりアプリ使用を防ぎたい場合に使用することが多いかと思います。

環境設定

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

  • Firebase RemoteConfig 6.12.0
  • Swift 5.1
  • Xcode 11.3
  • iOS 13.2.3

なお、本稿で記載している実装をまとめたサンプルを以下のようにGitHubに上げています。

イデアと実装

本章でどのようなアイデアに基づくのかと、具体的な実装に言及します。

概要

FirebaseのRemoteConfigを使用することで以下を管理します。

  • 対象アプリバージョン(本バージョン未満の場合にダイアログを表示する)
  • ダイアログのタイトル
  • ダイアログのメッセージ
  • ダイアログのOKボタン押下時の遷移先URL(App Store や Sorryページ)

それぞれを一項目として管理しても良いでしょうし、JSONでひとまとまりにしても良いかと思いますが本稿ではJSONとして格納した場合に関して記載します。

アプリ側ではFirebaseで設定されたバージョンと現在のアプリバージョンを比較し、現在のバージョンがFirebaseの設定バージョン未満だった場合にダイアログを表示するような機構を導入します。上記により、本稿が目的とする機構を実現することができます。

Firebase側の設定

Firebase側の設定に関して記載します。「概要」にても記載しました通り、本稿ではJSON形式でデータの登録を行います。パラメータは force_alert_information命名しました。行いたい挙動としては、該当バージョンアップ未満のアプリの場合にアラートを表示し、OKボタン押下でなんらかのWebページに遷移、ストアに遷移、もしくは何もせずにアラートを表示し続けた状態とするとします。これらを実現するために必要な情報は下記です。

  • title - アラートのタイトルに使用
  • message - アラートのメッセージに使用
  • version - アラートの表示可否で使用する基準バージョン
  • url - okボタン押下時の遷移先URL

実際に設定を行います。FirebaseのプロジェクトページのRemoteConfigからパラメータを追加することができます。 パラメータを追加 > 右側の {}を押下することでJSON形式でのパラメータ追加が可能になります。 f:id:Iganin:20191208215732p:plain f:id:Iganin:20191208215855p:plain

JSON形式でのパラメータ追加時は専用のEditorが表示され、JSON形式に添わない場合はエラーが表示され保存ができません。そのためJSON形式に添わない文字列を保存してしまい不具合が発生してしまう、というような事態は避けられるようになっています。

f:id:Iganin:20191208220051p:plain

RemoteConfigのWrapperクラスの作成

アプリ側でのRemoteConfigを扱うwrapperクラスを作成します。具体的な設定・導入までの手順は公式ドキュメントをご確認ください。本稿では、導入までは完了している前提で記載を進めます。

実装概要

以下に作成したクラスを記載します。コードにコメントとして直接詳細を記述していますが、主な注意点は下記です。

  • Debug時の設定
    • 実プロダクトで毎回fetchは取得上限回数に当たる可能性があるため厳しいですが、Debug時は即座に確認したいことが多いため、debugとreleaseで取得間隔を切り分けdebug時は0としています
  • 取得間隔
    • 1時間あたりの取得回数上限があるため、前回取得時より10分の間隔を設けています
  • デフォルト値の扱い
    • plistで扱う方法もあるかと思いますが、コード内で完結させたかったためenumでパラメータキーを定義し、デフォルト値を持たせています。
import Foundation
import FirebaseRemoteConfig

// 取得するパラメータを定義します
enum RemoteConfigParameterKey: String, CaseIterable {
    case forceAlertInformation = "force_alert_information"
    
    var defaultValue: NSObject? {
        switch self {
        case .forceAlertInformation: return ForceAlertInformation.defaultValue()
        }
    }
}

// RemoteConfigの設定用Protocolです
protocol RemoteConfigServiceProtocol {
    func fetchAllData()
}

// RemoteConfigのプロパティ取得用Protocolです
protocol RemoteConfigPropertyProvider {
    func getForceAlertInformation() -> ForceAlertInformation?
}

final class RemoteConfigService: RemoteConfigServiceProtocol {
    
    // Protocolを使用してDI時にモックとの入れ替えが可能なようにインスタンスとして扱うようにしています
    // Singletion
    static let shared = RemoteConfigService()
    
    private init() {
        remoteConfig = RemoteConfig.remoteConfig()
        // releaseビルドではない場合は取得感覚を0としています
        if !AppUtility.isRelease {
            remoteConfig.configSettings.minimumFetchInterval = 0.0
        }
        
        // 全てのデータを取得する前提で定義されているパラメータキーに対するデフォルト値を全て入れています
        remoteConfig.setDefaults(makeDefaultValues(forKeys: RemoteConfigParameterKey.allCases))
    }
    
    // MARK: - Prpoerty
    private let remoteConfig: RemoteConfig
    private var expirationDuration: TimeInterval {
        // debugビルドでは即時反映, releaseビルドでは一定時間あけるようにします
        switch AppUtility.buildType {
        case .debug: return 0.0
        case .release: return 10 * 60 // 10分間
        }
    }
    
    // MARK: - Function
    func fetchAllData() {
        remoteConfig.fetch(withExpirationDuration: expirationDuration) { [weak self] (fetchStatus, error) in
            guard error == nil else { return }
            
            switch fetchStatus {
            case .success: self?.remoteConfig.activate(completionHandler: { error in
                if let error = error {
                    print(error.localizedDescription)
                }
            })
            case .failure, .noFetchYet, .throttled: break
            @unknown default: break
            }
        }
    }
    
    // RemoteConfigParameterKeyで定義したKeyに対してデフォルト値を決定し代入します
    private func makeDefaultValues(forKeys keys: [RemoteConfigParameterKey]) -> [String: NSObject] {
        var defaultValues = [String: NSObject]()
        keys.forEach { key in
            // この部分は後ほど可能な限り修正します
            if let defaultValue = key.defaultValue {
                defaultValues[key.rawValue] = defaultValue
            }
        }
        return defaultValues
    }
}

extension RemoteConfigService: RemoteConfigPropertyProvider {
    private func getProperty(for key: RemoteConfigParameterKey) -> RemoteConfigValue? {
        return remoteConfig.configValue(forKey: key.rawValue)
    }
    
    func getForceAlertInformation() -> ForceAlertInformation? {
        // この部分は後ほど可能な限り修正します
        guard let data = getProperty(for: .forceAlertInformation)?.dataValue else { return nil }
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return try? decoder.decode(ForceAlertInformation.self, from: data)
    }
}

上記で使用しているForceAlertInformationの実装は下記です。

import Foundation

struct ForceAlertInformation: Codable {
    let title: String
    let message: String
    let version: String
    let url: URL?
}

extension ForceAlertInformation {
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        title = try container.decode(String.self, forKey: .title)
        message = try container.decode(String.self, forKey: .message)
        version = try container.decode(String.self, forKey: .version)
        let urlString = try? container.decode(String.self, forKey: .url)
        url = urlString == nil ? nil : URL(string: urlString!)
    }
}

extension ForceAlertInformation: RemoteConfigDefaultValueProvidable {
    static func defaultValue() -> NSObject? {
        let defaultValue = ForceAlertInformation(
            title: "確認",
            message: "新しいバージョンのアプリがあります。アップデートをお願いします。",
            version: "1.0.0",
            url: nil)
        let encoder = JSONEncoder()
        encoder.keyEncodingStrategy = .convertToSnakeCase
        guard let defaultData = try? encoder.encode(defaultValue) else { return nil }
        return NSData(data: defaultData)
    }
}

RemoteConfigの設定値の取得タイミング

applicationDidBecomeActive:のタイミングで取得するよう実装しています。このタイミングで行うことにより、アプリがタスクキルされていない場合でも background <-> forgroundの遷移で新しい値を取得・反映させることが可能です。

AppDelegate {
    func applicationDidBecomeActive(_ application: UIApplication) {
        // foreground <-> background遷移時に状態の変更を反映できるよう
        // このタイミングでRemoteConfigの設定値取得を行います
        RemoteConfigService.shared.fetchAllData()
    }
}

アプリ側の強制ダイアログ表示の実装

実装例

本稿のサンプルでは単純なMVC構成で作成しています。MVVMViperなど使用するアーキテクチャで具体的な実装は変わってくるかとは思いますが、その場合は初期表示される画面のViewModelPresenterで判定し表示を行うように実装するなどしていただけると良いかと思います。

バージョンの大小判定ロジック

Stringが比較演算を用いてそのまま比較可能なためバージョン判定時に下記のようにしてしまいがちです。

if currentVersion < criteriaVersion {
    // Alert表示
}

しかし、この方法ですと潜在的なバグを含んでしまいます。例えば、 currentVersion = "3.10.0"criteriaVersion = 3.9.0 の比較を行う場合本来であればcurrentVersionの方がcriteriaVersionよりも大きいためアラートが表示されて欲しくないですが、比較演算子で比較を行なってしまうと 3.10.0の方が3.9.0のより小さいと判定されアラートが表示されてしまいます。

これはおそらくですが、内部的に少数の比較となってしまっており、 3.1と3.9の比較が行われたことにより、3.10.0の方が3.9.0より小さいと判断されているために発生しています。そこで下記のように . で各桁の数字を分離し、各桁の数字の大小比較を行うことでバージョンの大小判定を行います。

struct VersionUtility {
    // 現在のバージョンと比較対象のバージョンを比べ強制アラートの表示が必要かを判定します
    func isForceAlertRequired(currentVersion: String, criteriaVersion: String) -> Bool {
        
        var currentVersionNumbers = currentVersion.components(separatedBy: ".").map { Int($0) ?? 0 }
        var criteriaVersionNumbers = criteriaVersion.components(separatedBy: ".").map { Int($0) ?? 0 }
        let countDifference = currentVersionNumbers.count - criteriaVersionNumbers.count
        
        // major versionから順に比較していき、同値でなくなった時に大小比較結果を返す
        for (current, criteria) in zip(currentVersionNumbers, criteriaVersionNumbers) {
            if current > criteria {
                return false
            } else if current < criteria {
                return true
            }
        }
        // 同値
        return false
    }
}

強制ダイアログの表示メソッド

OKボタン押下後にAlertが閉じないよう再度Alert表示のメソッドを呼び出しているところがポイントになります。

    func showForceAlert(information: ForceAlertInformation) {
        let alertController = UIAlertController(
            title: information.title,
            message: information.message,
            preferredStyle: .alert
        )
        let okAction = UIAlertAction(title: "OK", style: .default) { [weak self] _ in
            // okを押下後にAlertが非表示にならない用再度表示します
            self?.showForceAlert(information: information)
            if let url = information.url,
                UIApplication.shared.canOpenURL(url) {
                UIApplication.shared.open(url, options: [:], completionHandler: nil)
            }
        }
            
        alertController.addAction(okAction)
        present(alertController, animated: true, completion: nil)
    }

実際の判定処理

ViewControllerで行う実際の判定処理部分です。実プロダクトで使用する場合は、アラート表示後の処理を行うかどうか検討し、Bool値を返すようなメソッドに分割変更した方が良いかと思います。

    func showForceAlertIfNeeded() {
        guard let forceAlertInformation = remoteConfigPropertyProvider.getForceAlertInformation() else { return }
        
        let currentVersion = AppUtility.currentVersion
        let criteriaVersion = forceAlertInformation.version
        if VersionUtility().isForceAlertRequired(
            currentVersion: currentVersion, criteriaVersion: criteriaVersion) {
            showForceAlert(information: forceAlertInformation)
        }
    }

タイミング

ダイアログの表示の必要有無の判定および表示処理を実施するタイミングとして下記のライフサイクルを採用しています。

初期表示される画面のviewDidLoad

サンプルでは初期に表示されるViewControllerのViewDidLoad内で処理を行なっています。他の方法としては、例えば、 LauchScreenの表示後にLaunchScreenと同様の画面をUIViewControllerで表示し、そのviewDidLoadで判定を実施の後、ダイアログを表示するかTabBarControllerを表示するかだし分けるなどが考えられます。なお、後述する applicationDidBecomeActive のタイミングを拾えてしまい、同一の画面で2度実行されてしまう可能性あるため、そのような場合にはこのライフサイクルでの判定は不要になります。

    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
        showForceAlertIfNeeded()
    }
applicationDidBecomeActive

アプリがタスクキルされていない場合も考慮して、このライフサイクルでの判定も行います。本メソッドをフォローすることでアプリのbackground <-> forgroundの遷移でも判定を行うことができます。具体的な実装はアプリのメイン画面(例えばRootViewController)にNotification経由でライフサイクルを注入するのが良いと思います。

    func setup() {
        NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
    }
    
    @objc func applicationDidBecomeActive() {
        showForceAlertIfNeeded()
    }

合致するビジネス要件

RemoteConfig を使用した強制ダイアログ機構は大変便利なものではありますが、RemoteConfigの特性上ビジネス要件次第では採用が難しいパターンがあります。それはダイアログの表示を判断してから実際に表示されるまでのタイムラグを可能なかぎり0にしたい場合です。

Firebase RemoteConfigはその機能の仕様上、具体的には1時間あたりのフェッチ回数の上限があるという仕様上、前回の値所得時から新規の値取得時まで一定の期間設定値の再取得を行わないようになるパターンが多いです。したがって、このような場合にはダイアログに関するFirebase側の設定変更から実際のアプリ側への反映まで一定のタイムラグが発生してしまいます。

よって、設定変更から反映まで即座におこないたいという厳し目の要件の場合は合致しづらいですが、多少のタイムラグを許容できる場合は本記事の機構で十分実用にたりうると判断しています。

まとめ

強制ダイアログ表示はサービスメンテナンス時やなんらかの不具合によってアプリの全体的なバージョンアップが必要な時などに必要となり、実装しておいた方が良い機能かと思います。サーバーの初期通信などに強制ダイアログ表示機構の返却値等を入れた場合、サーバーがそもそも動作していない場合などにはダイアログ表示に必要な情報を取得することができません。

強制ダイアログ表示を行うための機構をサービスサーバーから分けることで、サーバーが機能していない場合でもメンテナンス中などの表示を行うことができます。機能要件の中で出てきた際には是非選択肢の一つとして検討してみてください。

参考資料

RemoteConfig

AdvcentCalendar

サンプル

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

参考