Iganinのブログ

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

【SwiftUI】BackButtonの文字を消す

語り尽くされている感があるが、今一度調べたので備忘録的にメモ。

tl;dr;

  • UINavigationBarのAppearnceをいじることで達成可能
  • navigationBarBackButtonHiddenにして、navigationItemをnavigationBarLeadingに追加でも対応できるが以下の機能が消える
    • SwipeGestureによる元の画面への遷移
    • 戻るボタン長押しによるStackの表示と選択による画面遷移

環境

以下の環境で確認

UIKit

まずUIKitの場合にどうするかを記述する。 これは非常に簡単に行うことができて、navigationItem.backButtonDisplayModeminimalにしてやれば良い。 iOS 14以上の制約はあるが、昨今は基本的にはこのサポートバージョンの条件を満たしていると思う。 backButtonDisplayMode | Apple Developer Documentation

SwiftUI

SwiftUIでどうするかを記述する。

UINavigationBarのAppearanceをいじる

おそらくこの方法が筋が良いと思う。 以下のようにすることで、BackButtonのテキストを非表示にすることができる。

appearance.backButtonAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.clear]

この方法であれば、後述の方法では無効化されてしまうデフォルトの以下の機能も無効化されない。

  • Swipe Gestureによる画面遷移
  • 長時間押下によるStackの表示 + 指定した画面への遷移

ToolbarItem(placement: .navigationBarLeading)

navigationBarBackButtonHidden()でBackButtonを非表示にして、ToolbarItem(placement: .navigationBarLeading) で独自の戻るボタンを置く方法。 以下のようにViewModifierを作成すると設定がだいぶ楽になる。

struct MinimalNavigationBarBackButton: ViewModifier {
  @Environment(\.dismiss) var dismiss

  public func body(content: Content) -> some View {
    content
      .navigationBarBackButtonHidden(true)
      .toolbar {
        ToolbarItem(placement: .navigationBarLeading) {
            Button {
                dismiss()
            } label: {
                Image(systemName: "chevron.backward")
            }
            .foregroundStyle(Color.black)
        }
      }
  }
}

直感的な方法ではあるが、デフォルトである以下の機能が無効になる。

  • A Swipeによる元の画面への画面遷移
  • B 戻るボタンの長時間押下によるStackの表示 + 指定Stackへの遷移

AについてはUINavigationControllerをいじることで実装できるが、全体に適用するとBackButtonがHiddenでもSwipeで戻れてしまうので制御が必要。 Bもおそらく実装できるが手間はかかりそう。

extension UINavigationController: UIGestureRecognizerDelegate {
    override open func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = self
    }

    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return viewControllers.count > 1
    }
}

その他

以下の方法も検討できるが、BackButton以外の挙動が変わりそうなのでその辺りの考慮が必要そう。

  • 画面遷移と同時にUserのnavigationTitleを空文字にする
  • toolbarRoleにeditorを指定

Reference

【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】ARKitのFace Trackingの結果をobjファイルとして出力する

TL;DR;

  • ARSessionでARFaceTrackingConfigurationを走らせる
  • ARFaceGeometryをMDLAssetに変換する
  • MDLAssetのexport(to:)メソッドを使う

objファイルについて

いわゆるobj形式のファイル。下記の記事が参考になった。 ブログ記事からの引用ではあるが、objファイルは頂点座標,頂点法線,頂点テクスチャ座標から構成されるポリゴンを記述できる. 頂点座標のみや座標と法線のみなどでの記述も可能である.とのこと。 OBJ形式 - PukiWiki for PBCG Lab

objファイルをXcodeで閲覧すると下記のようになる。 こちらは自身の顔をARKitのFace Trackingしたデータをobjファイルに変換して出力したもの、本稿ではARKitでの検知結果を下記のようなobjファイルとして出力することを目指す。

f:id:Iganin:20211213044526p:plain

ARKit Face Tracking Configuration

ARKitのFace Trackingに関して簡単に記す。 ARSessionを生成し、runする際にARFaceTrackingConfigurationを指定することで、機能を使用できる。 ( Tracking and Visualizing Faces) arSession.run(ARFaceTrackingConfiguration())

ARSCNViewを使用することで、ARSCNViewDelegateのメソッドの renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor)を経由してFace Trackingの情報を取得できる。

if let faceAnchor = anchor as? ARFaceAnchor {
  // do something with ARFaceAnchor
}

ARFaceAnchorからは目の位置、顔が向いている方向などさまざまな情報を取得できる(ARFaceAnchor)が、本稿では geometry: ARFaceGeometryを使用する。 ARFaceGeometryは顔の形状を三角形のメッシュで近似的に検出した際の各頂点やテクスチャの座標系を提供してくれる。(ARFaceGeometry) ARFaceGeometryを使用すれば顔の形状を表示することができる、簡単なところだとARSCNFaceGeometryを使用してARSCNViewに描画することができる。

    func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
        guard let device = renderer.device else { return nil }
        let node = SCNNode(geometry: ARSCNFaceGeometry(device: device))
        return node
    }

以下ではこのデータをobjファイルに変換する方法を考える。

ARKit の Face Geometryデータをobjファイルに変換する

Face Geometryをobjファイルに変換するにあたって、Face GeometryをMDLAssetに変換する。(MDLAsset) MDLAssetの説明を読むと、export(to:)メソッドを使用することで、MDLAssetの内容をファイルに出力することができる。(export(to:)) 以下、Face GeometryをMDLAssetに変換する方法を記載する。

Face GeometryをMDLAssetに変換するにあたって、大まかに下記の手順を取る。

  • MDLMeshBufferDataAllocatorの作成
  • MDLSubmeshの作成
  • MDLMeshの作成
  • MDLAssetの作成
  • MDLMeshをMDLAssetに含める
  • MDLAssetからobjファイルを出力する

MDLMeshBufferDataAllocatorの作成

MDLAssetの初期化にはよくURLを指定してリソースを読み込む方法が使われるが、今回はbufferAllocatorを使用する。(init(bufferAllocator:)) この方法はドキュメントにもある通り、プログラムでMDLAssetを作成するのに使用される。 MDLMeshBufferAllocatorの生成部分の実装は下記のようになる。

let allocator = MDLMeshBufferDataAllocator()

以下ではindexやvertex、coordinateの情報をallocatorを使用しbufferに変換して使用していく。

MDLSubmeshの作成

次にMDLSubmeshを作成する。MDLSubmeshの作成のためにはindexBufferが必要になる。今回の顔のMeshに関してはARFaceGeometryがtriangleIndicesを持っているためそれを使う。 初期化の実際の実装は下記のようになる。

let triangleIndicesBuffer = allocator.newBuffer(
    with: Data(bytes: triangleIndices, count: triangleIndices.count * MemoryLayout<Int16>.stride),
    type: .index
)

let subMesh = MDLSubmesh(
    indexBuffer: triangleIndicesBuffer,
    indexCount: triangleIndices.count,
    indexType: .uInt16,
    geometryType: .triangles,
    material: nil
)

MDLMeshの作成

MDLMeshを作成する。Mesh作成にあたって必要なindicesは作成しsubmeshに渡したので、ここでは各頂点と各頂点に対応したcoordinateを渡す。 各頂点のverticesBufferとcoordinateBufferの実装は下記のようになる。Data生成時に使用するデータ容量を計算する際にMemoryLayout.strideとし各データ型のメモリ容量を考慮することに注意する。 coordinateは直感的には2 vector必要じゃないかという気もするが、法線が決まれば平面の座標系も決まるはずなので、多分法線ベクトルを意味していると思われる(関連ドキュメントはまだしっかりと読めていない...) vertexはSIMD3なのでとても素直に理解しやすいと思う。

let verticesBuffer = allocator.newBuffer(
    with: Data(bytes: vertices, count: vertices.count * MemoryLayout<SIMD3<Float>>.stride),
    type: .vertex
)

let coordinatesBuffer = allocator.newBuffer(
    with: Data(bytes: textureCoordinates, count: textureCoordinates.count * MemoryLayout<SIMD2<Float>>.stride),
    type: .vertex
)

MDLMeshを作成するには、descriptorを指定する必要がある。(init(vertexBuffers:vertexCount:descriptor:submeshes:)) MDLVertexDesciptorは下記のように作成する。今回はデータとしてvertexとcooridinateを使用しているため、それらの設定を行なっている。 なぜこの設定が必要かについては以下のStackOverflowの回答が詳しい。(Save ARFaceGeometry to OBJ file)

let vertexDescriptor = MDLVertexDescriptor()
vertexDescriptor.attributes[0] = MDLVertexAttribute(
    name: MDLVertexAttributePosition,
    format: .float3,
    offset: 0,
    bufferIndex: 0
)
vertexDescriptor.attributes[1] = MDLVertexAttribute(
    name: MDLVertexAttributeTextureCoordinate,
    format: .float2,
    offset: 0,
    bufferIndex: 1
)
vertexDescriptor.layouts[0] = MDLVertexBufferLayout(
    stride: MemoryLayout<SIMD3<Float>>.stride
)
vertexDescriptor.layouts[1] = MDLVertexBufferLayout(
    stride: MemoryLayout<SIMD2<Float>>.stride
)

上記を使用してMDLMeshを生成する。

let mdlMesh = MDLMesh(
    vertexBuffers: [verticesBuffer, textureCoordinatesBuffer],
    vertexCount: vertices.count,
    descriptor: vertexDescriptor(),
    submeshes: [subMesh]
)
mdlMesh.addNormals(withAttributeNamed: MDLVertexAttributeNormal, creaseThreshold: 0.5)

MDLMeshのaddNormals()を呼び出すことで、実際のデータの生成を行う。

MDLAssetの作成, MDLMeshをMDLAssetに含める, MDLAssetからobjファイルを出力する

MDLAssetを作成し、MDLMeshをMDLAssetに含める。

let asset = MDLAsset(bufferAllocator: allocator)
asset.add(mdlMesh)

最後に作成したMDLAssetをexportする。この際に指定する引数のURLはMDLAssetのドキュメントに記載されている通りファイルURLでなければならないことに注意する。

let saveFile = FileManager.default.createDocFile(ext: "obj")
try asset.export(to: saveFile)

参考

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

参考