Iganinのブログ

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

iOSDC 2019に参加しました

はじめに

9/5(木) - 9/7(土)に開催されたiOSDC 2019に参加しました。今年も昨年同様に早稲田大学 西早稲田キャンパスで開催されました。 今回は昨年に引き続き2回目の参加です。9/5(木)が前夜祭、9/6(金) - 9/7(土)がDay1 Day2という位置付けで今年はお茶会などの新しい試みもありました。

感想

セッションについて

昨年同様、実務に直結しそうな実践的な内容が多かったように思います。 特に、以下のセッションが個人的に印象的でした。

Swiftにおけるインポートとリンクの仕組みを探る(Kishikawa Katsumiさん)

Swiftにおけるインポートとリンクの仕組みを探る - Speaker Deck

ライブラリのインポートやリンクのエラーはアプリ開発をしているとしばしば遭遇します。 その都度情報を集めるのですが、どれも断片的な情報が多く、またこうしたらこうなった、というような体験談が多くを占めています。

その中で本セッションの内容はインポートとリンクに関して体系立てて説明されており、問題に対処する上での本質的な知識を得られると感じました。 このようなまとまった情報はあまり世の中になく、非常に貴重なものだと考えています。 セッション内では応用編の話は時間の関係上されませんでしたが、資料は公開されていますので時間を見つけて挑戦したいと思います。

詳解 Auto-Renewable Subscription(rocknameさん)

詳解 Auto-Renewable Subscription / Detailed Auto-Renewable Subscription - Speaker Deck

課金の基礎的な話や実際の挙動をシーケンス図を交えて説明してくださり大変参考になりました。 また、Graceful Periodやサーバー間通信など最新の課金周りの情報に加え、polling updateやアプリ側の実践的な実装の話もあり、 Auto-Renewable Subscriptionを実装する上での一通りの知識はこちらのスライドを見ることで把握できるのではないかと思います。

運営の方々について

非常に手際よく準備や対応をされており素晴らしいなと感じました。 day2の最後にハイタッチした場面が印象に残っているのですが、 とても楽しそうに対応されていてこちらも今回参加して本当によかったと感じました。

最後に

カンファレンスに参加するというと、セッションの動画やスライドは後で確認できるし、別に参加する必要はないんじゃないか、という意見も耳にします。 しかしながら、今回のカンファレンスでなんども耳にした双方向のコミュニケーションは実際にその場にいないと行うことはできません。 また、ただ学ぶだけでなく、久しぶりに友人と会うのは嬉しいですし、単純に尖った人々と同じ空間にいるというだけでもモチベーションを刺激され、 とても前向きな気持ちになれます。

今年もとても素敵な時間を過ごすことができました。 参加してとてもよかったです。ありがとうございました。来年も参加できればと思います。楽しみにしています。

【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:など)

参考

【iOS】UITableViewのtableFooterViewを使用する際のはまりどころ

はじめに

UITableViewを使用する際に繰り返しの要素はsecitoncellを用いて表示することが多いかと思います。 主にこれらがUITableViewのメイン要素となり、各sectionのサマリや補足情報をsectionHeaderViewsectionFooterViewに記載します。 そして、繰り返し要素そのものには直接関係しませんが、繰り返し情報の前提となる条件や全体に対するなんらかの補足情報、 もしくは広告を表示したいとなった場合、UITableView全体のheaderfooterであるtableHeaderView, tableFooterViewを使用することになるかと思います。

ただし、特にtableFooterViewでは独特の癖があり、UIViewControllerchildViewController化しtableFooterViewに追加しようとした際に少々難儀しました。 次回同様のことがあった際に、対応方法を即座に思い出せるよう記事として残します。

環境設定

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

  • Xcode10.3.0
  • Swift 5.0.1

注意点

footerに設定するView自体のレイアウトではauto layoutを使用しない

tableFooterViewheightを以下のようにautoLayoutを用いて指定すると、tableFooterViewが正常に表示されません。 例えば下記のような書き方をすると、レイアウトが崩れてしまいます。

footerView.translatesAutoresizingMaskIntoConstraints = false
footerView.heightAnchor.constraint(equalToConstant: 50.0)

なお、translatesAutoresizingMaskIntoConstraintsfalseにしていてもconstraintを使用していなければ正常に表示されました。

viewのレイアウトをしてからtableFooterViewに代入する

tableFooterViewに該当のviewを設定してから、footerView内のsubViewのレイアウトを変更しようとすると変更が正常に反映されません。例えば下記のコードのようにtableFooterViewsubviewとしてfooterInnerViewを追加して、footerInnerView.center = footerView.centerしてからtableFooterViewに設定した場合は、正しく中央に配置されますが、tableFooterViewに設定してから先ほどのレイアウトを行うと中央に正しく配置されません。

        footerView.backgroundColor = .black
        footerView.frame.size.height = footerHeight
        footerView.frame.size.width = UIScreen.main.bounds.width
        
        let footerInnerView = UIView(frame: CGRect(x: 0, y: 0, width: 100.0, height: 40.0))
        footerInnerView.backgroundColor = .yellow
        footerView.addSubview(footerInnerView)
        footerInnerView.center = footerView.center
        tableView.tableFooterView = footerView

f:id:Iganin:20190819160808p:plain
レイアウトしてからtableFooterViewとして設定

f:id:Iganin:20190819160838p:plain
tableFooterViewに設定してからレイアウト

ChildViewControllerをfooterViewに設定する際の注意点

child化したUIViewControllertableFooterViewに追加する際にも、同様に追加~レイアウトを行う順番に気をつけないとレイアウト崩れが発生するため注意が必要です。以下のコードではレイアウトがうまくいきますが、tableView.tableFooterView = footerViewの後にviewController.view.frame = footerView.frameを実行するとレイアウトが崩れます。

        // ChildViewController
        viewController.view.backgroundColor = .blue
        addChild(viewController)
        footerView.addSubview(viewController.view)
        viewController.didMove(toParent: self)
        viewController.view.frame = footerView.frame
        
        tableView.tableFooterView = footerView

また、レイアウトを以下のようにautoLayoutを用いて実現しようとするとレイアウトが崩れるため注意が必要です。

        // ChildViewController
        viewController.view.backgroundColor = .blue
        addChild(viewController)
        footerView.addSubview(viewController.view)
        viewController.didMove(toParent: self)
        
        viewController.view.translatesAutoresizingMaskIntoConstraints = false
        viewController.view.topAnchor.constraint(equalTo: footerView.topAnchor)
        viewController.view.leadingAnchor.constraint(equalTo: footerView.leadingAnchor)
        viewController.view.trailingAnchor.constraint(equalTo: footerView.trailingAnchor)
        viewController.view.bottomAnchor.constraint(equalTo: footerView.bottomAnchor)
        
        tableView.tableFooterView = footerView

UIViewControllerchild化は同じ手続きが多いため、以下のようなメソッドを用意している場合があると思います。 この場合、autoLayoutを使用してレイアウトを決定しているためレイアウトが崩れてしまいます。 UIViewControllerchildViewControllerとして追加した際のレイアウトがうまくいかなかった際は原因の一つとして疑ってみてください。

extension UIViewController {
    func addChildViewController(_ child: UIViewController, on containerView: UIView) {
        self.addChild(child)
        containerView.addSubview(child.view)
        child.didMove(toParent: self)
        child.view.translatesAutoresizingMaskIntoConstraints = false
        child.view.topAnchor.constraint(equalTo: containerView.topAnchor)
        child.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor)
        child.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor)
        child.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
    }
}

まとめ

tableFooterViewを使用する際には以下2点に気をつける必要があります。

  1. footerに設定するView自体のレイアウトではauto layoutを使用しない
  2. tableFooterViewにViewを追加する前にレイアウトを済ませておく

参考

【iOS】サーバーからのリモート通知を実施するために必要なアプリ側対応の概略

はじめに

Push通知はサービスユーザーに情報を送る上でとても有効な手段です。最近では、Firebase の Cloud Messagingの登場により、キャンペーンのプッシュや固定文言の定期PushはFirebase側で取り扱うことができるようになりましたが、ユーザー個別に動的な内容を送りたい(ユーザー個人に特化したレコメンドなど)場合などは、それだけでは十分ではないことが多く、APNsを介したリモートPush通知の仕組みはいまだに重要であり続けています。

本記事では自身の理解の整理もかねて、Push通知を送るために必要な設定を箇条書きレベルで記載していきます。

環境設定

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

  • Xcode10.2.1
  • Swift 5.0.1

証明書/Keys

Establishing a Certificate-Based Connection to APNs

Push通知を送ることを可能にするために専用の証明書を作成する必要があります。本番用と開発用があるため、それぞれ作成し、サーバー側に配置します。Firebase Cloud Messagingを使用する場合もこの証明書は必要であり、 Projectの設定画面からアップロードすることになります。

Establishing a Token-Based Connection to APNs

今から開発を始める場合はPush Notification Authentication keyを使用するという選択肢もあります。 JWT(JSON Web Token) をベースとして接続を形成します。証明書は1年に1回更新が必要ですが、Keysの場合は更新が必要なく、運用がより簡易的になります。

アプリ側でPush通知を行うための設定をする

アプリ側でリモートPush通知を行うため設定をします。対応内容は大まかに分けて以下です。

  • Apple Developer CenterのApp側でRemote Push Notificationの使用設定
  • プリプロジェクト側でRemote Push通知の使用設定

Apple Developer CenterのApp側でRemote Push Notificationの使用設定

developer center上でApp ID Configurationを変更します。

  1. Certificates, Identifiers & Profilesに移動
  2. 該当アプリのidentifier表示
  3. Capabilitiesの中のRemote Push Notificationにチェック

プリプロジェクト側でRemote Push通知の使用設定

Registering Your App with APNs

アプリのProject側でCapabilityを変更します。詳細な内容は上記リンクのEnable the Push Notifications Capabilityに記載があります。

  1. プロジェクトのアプリ Targetを選択
  2. Capabilitiesの中のPush Notificationsをチェック(Push起因でアプリを起動し、なんらかの処理を行いたい場合は、Background Modeの中のRemote Notificationsにもチェックを入れます)
  3. entitlementのPush Notificationsの設定を追加(ASP Environment)

3について、entitlementがまだない場合にはdevelopment環境の設定のentitlementが生成されます。 Remote Push Notificationsの設定には開発環境設定(development)、本番環境設定(production)があるため、 本番環境用のEntitlementと開発環境用のEntitlementを分けて生成することをお勧めします。

検証、本番用それぞれ作成後は、Build Settings > Code Signing EntitlementにてBuild ConfigurationsごとにEntitlementを分けます。

実装

Asking Permission to Use Notifications

アプリの実行時にPush通知の設定を行います。主な対応内容は以下です。

  1. Notificationsの利用許諾
  2. APNsにDeviceTokenの登録
  3. 自身のNotifications Provider ServerにDeviceTokenを登録

大まかな流れは以下の様になります。

  1. requestAuthorization(options:completionHandler:)を実施
  2. CompletionHandler内でerrorとgrantedで条件分岐を実施、 error == nil, granted == trueの場合に3を実施
  3. registerForRemoteNotifications()でDeviceTokenをAPNsに登録
  4. 3の成功、失敗がAppDelegateに通知される
    1. 成功 application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
    2. 失敗 application(_:didFailToRegisterForRemoteNotificationsWithError:)
  5. 4にて成功した場合に自身のサービスのPush通知発行サーバーに4で渡されたDeviceTokenを送信

AppleのドキュメントにNever cache device tokens in local storage.と記載がありますので、 Device Tokenをローカルに保存し、それを使用し続けるのは避けた方が良さそうです。

またrequestAuthorizationは初回実行時にのみダイアログが表示されます。 通知が許諾されているか否かはサービス上とても大きな意味を持ちますので、 ユーザー体験を考慮しながら初回のrequestAuthorizationが適切なタイミングで実行されるよう 体験設計を慎重に行う必要があります。

まとめ

以上でリモートPush通知を実行するためのアプリ側対応は完了です。まとめますと下記のようになります。

  • 証明書/Keys作成(サーバーへの登録は別途行う必要があります)
  • アプリの設定(Identifier・プロジェクト双方のCapabilities設定、Entitlement登録)
  • 実装(許諾確認、DeviceTokenのAPNs・サーバー双方への登録)

参考

体験設計について

動画

APNsおよび証明書、認証Keyについて

アプリ側設定について

サーバー側設定について

【iOS】UINavigationControllerでcompletionHandlerを伴ったpush/pop遷移ができるようにする

はじめに

画面のモーダル遷移を実現するUIViewControllerpresent(viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?)dismiss(animated: Bool, completion: (() -> Void)?) では completionがあり画面遷移完了後の処理を記述することができます。

一方でUINavigationControllerpushViewController(viewController: UIViewController, animated: Bool)popViewController(animated: Bool)ではcompletionがなくデフォルトでは、 画面遷移完了後の処理を記述することができません。本記事ではUINavigationControllerでもdismissメソッドと同様に 画面遷移完了後の処理を記述する方法を記載します。

環境設定

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

  • Xcode10.2.1
  • Swift 5.0.1

方法

UINavigationControllerに以下のメソッドを追加します。

transitionCoordinatorViewControllerの遷移に関するアニメーションを規定するProtocolで、 遷移実行時にUIViewControllertransitionCoordinatorプロパティに格納されます。

transitionCoordinatoranimate(alongsideTransition:, completion:)メソッドのcompletiontransitionの完了後に呼ばれるため、ここに実行したいメソッドを入れれば画面遷移完了後に メソッドを呼ぶという目的を達成できます。

// TransitionCoordinatorはViewControllerのtransitionに関するアニメーションを規定するProtocolです
// transitionCoordinatorはactiveなtransitionやpresentation/dismissalが実行されている時に
// viewControllerのtransitionCoordinatorに含まれ、transitionが完了したタイミングで解放されます
extension UINavigationController {
    
    func popViewController(animated: Bool, completion: @escaping (() -> Void)) {
        popViewController(animated: animated)        
        if animated, let coordinator = transitionCoordinator {
            coordinator.animate(alongsideTransition: nil) { _ in
                // coordinatorで実行するanimationの完了時にcompletionが実行されます
                // 本メソッドにおいてcontextが同じanimationはpopViewControllerのため
                // pop完了後に本メソッドが実行されます
                completion()
            }
        } else {
            completion()
        }
    }
    
    func pushViewController(_ viewController: UIViewController, animated: Bool, completion: @escaping (() -> Void)) {
        pushViewController(viewController, animated: animated)
        if animated, let coordinator = transitionCoordinator {
            coordinator.animate(alongsideTransition: nil) { _ in
                // coordinatorで実行するanimationの完了時にcompletionが実行されます
                // 本メソッドにおいてcontextが同じanimationはpushViewControllerのため
                // push完了後に本メソッドが実行されます
                completion()
            }
        } else {
            completion()
        }
    }
}

ログの出力

以下のメソッドでログ出力を行い呼ばれる順番を確認しました。

// 呼び出し元
viewWillDisappear
viewDidDisappear
viewWillAppear
viewDidAppear

// 呼び出し先
viewWillDisappear
viewDidDisappear
viewWillAppear
viewDidAppear

// Completion
// UIViewController present および dismiss
// UINavigationController push および pop

結果は以下のようになりました。Aが呼び出し元のViewController、Bが遷移先のViewControllerです。 なおanimatedはtrueであってもfalseであっても同様の結果になりました。

// UIViewControllerのpresentおよびdismiss
A viewWillDisappear
B viewWillAppear
B viewDidAppear
A viewDidDisappear
present completion
B viewWillDisappear
A viewWillAppear
A viewDidAppear
B viewDidDisappear
dismiss completion

// 本記事でのUINavigationControllerのpushおよびpop
A viewWillDisappear
B viewWillAppear
A viewDidDisappear
B viewDidAppear
push completion
B viewWillDisappear
A viewWillAppear
B viewDidDisappear
A viewDidAppear
pop completion

modalでの遷移かpushでの遷移かの違いにより、viewWillAppearやviewDidAppearなどライフサイクル部分の順序に違いはありますが、 completionの呼ばれるタイミングは遷移元および遷移先のライフサイクルが全て完了した後であり、 presentメソッドやdismissメソッドのcompletionで実行しようとしていることをするには問題ないであろうことがわかります。

なお、補足ですが、たまにみられる以下の書き方ですと、completionのタイミングがvewDidDisappearやveiwDidAppearの前となってしまい、 presentやdismissとタイミングが大きく異なるため意図した挙動にならない可能性が高いです。

extension UINavigationController {
    
    func popViewController(animated: Bool, completion: (() -> Void)? = nil) {
        CATransaction.begin()
        CATransaction.setCompletionBlock(completion)
        popViewController(animated: animated)
        CATransaction.commit()
    }
    
    func pushViewController(_ viewController: UIViewController, animated: Bool, completion: (() -> Void)? = nil) {
        CATransaction.begin()
        CATransaction.setCompletionBlock(completion)
        pushViewController(viewController, animated: animated)
        CATransaction.commit()
    }
}

// ログの結果
A viewWillDisappear
B viewWillAppear
push completion
A viewDidDisappear
B viewDidAppear
B viewWillDisappear
A viewWillAppear
pop completion
B viewDidDisappear
A viewDidAppear

参考

【iOS】IBDesignableで画像をscaleAspectFitやscaleAspectFillで表示できるUIButtonを作る

はじめに

通信結果から画像を取得し、その画像をAspectFill(もしくはAspectFit)で設定して、その部分を押下すると詳細画面に遷移するというのはアプリ開発においてよくあるパターンだと思います。 例えば、メディア系のアプリでなんらかの特集をくみ、その特集の写真をタップすると詳細記事に遷移するといった場合です。

このような場合の一つの対応方法はUIViewの上にUIImageViewを貼り、その上に同じ大きさのUIButtonを載せるというものです。 ただ、このような実装は単純に手間なだけでなく、ボタン押下時の自然なアニメーション(全体に薄暗い反転がかかるもの)がなくなってしまいがちです。 本記事ではUIButtonの拡張クラスを用意し、UIButtonそのものに上記のような表示の機能を持たせる方法を記載します。

環境設定

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

  • Xcode10.2.1
  • Swift 5.0.1

方法

肝となる部分は、UIButtonの設定項目である、 contentHrozontalAlignmentcontentVierticalAlignmentです。 これらは、それぞれButton内部のコンテンツ(titleLabel、imageView)をUIButton内にどのように配置するかを決定します。 デフォルトではこれらの値はそれぞれ .center.centerになっており、画像のサイズ以上に拡大することはできません。 それぞれを.fill, .fillとすることでボタンのサイズに追随して画像サイズが決定するようになります。

枠いっぱいまで広がった際に、画像部分がどのように表示されるか(短い方に合わせてAspect比を保って画面いっぱい、 長い方に合わせてAspect比を保って画面いっぱい)はUIButtonimageView.contentModeを設定することで指定できます。

あとは通信取得した画像をUIButtonに設定すれば「はじめに」に記載した様なUIを実現できます。 ※余談ですがURLから取得した画像の設定は例えばKingfisherでは button.kf.setImage(with: url, for: .normal)のように実現できます。

import Foundation
import UIKit

@IBDesignable
public final class ImageDesignableButton: UIButton {
    
    // MARK: - IBInspectable
    
    /// 画像部分のCornerRadius
    @IBInspectable
    public var imageCornerRadius: CGFloat {
        set { imageView?.layer.cornerRadius = newValue }
        get { return imageView?.layer.cornerRadius ?? 0.0 }
    }
    
    /// 画像部分のBorderWidth
    @IBInspectable
    public var imageBorderWidth: CGFloat {
        set { imageView?.layer.borderWidth = newValue }
        get { return imageView?.layer.borderWidth ?? 0.0 }
    }
    
    /// 画像部分のBorderColor
    @IBInspectable
    public var imageBorderColor: UIColor {
        set { imageView?.layer.borderColor = newValue.cgColor }
        get { return UIColor(cgColor: imageView?.layer.borderColor ?? UIColor.clear.cgColor) }
    }
    
    /// Button部分と画像部分のpadding
    @IBInspectable
    public var padding: CGFloat = 0 {
        didSet {
            // imageEdgeInsetsを設定してボタンの外枠と画像部分に隙間を作っています
            imageEdgeInsets = UIEdgeInsets(top: padding, left: padding, bottom: padding, right: padding)
        }
    }
    
    // MARK: - Life Cycle
    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    public override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        commonInit()
    }
    
    public override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }    
}

// MARK: - Private Function
private extension ImageDesignableButton {
    
    func commonInit() {
        // 画像部分の設定をしています
        // この部分はIBInspectableで外にだしてもいいかもしれません
        // image
        imageView?.contentMode = .scaleAspectFill
        
        // horizontal, verticalそれぞれの設定値を.fillとする
        // ことでコンテンツ部分いっぱいに画像が配置されるようになります
        contentHorizontalAlignment = .fill
        contentVerticalAlignment = .fill
        
        // 画像の外枠を丸くしたり、枠を作ったりする設定です
        // 必要に応じて使ってください
        // image layer
        imageView?.layer.borderColor = imageBorderColor.cgColor
        imageView?.layer.cornerRadius = imageCornerRadius
        imageView?.layer.borderWidth = imageBorderWidth
    }
    
}

なお、paddingに関してtop, left, right, bottomと別々の値を使用したい場合は、privateでUIEdgeInsetsを定義して、 topPadding等をCGFloatで定義し、それぞれのPaddingからInsetsをいじることで似たように設定できます。

まとめ

Paddingをつけながら画像をaspectFillやaspectFitでボタンのサイズに追随して表示するUIButtonのカスタムクラスをご紹介しました。 ぜひ使ってみてください。

参考

【iOS】Place Holder(プレースホルダー)付きのUITextViewを作成する

はじめに

複数行のユーザーインプットを受け付けたいとき、UITextViewをよく使用します。 ユーザーがどのような内容を入力すれば良いかのヒントを与えるためにプレースホルダーの使用を検討することもよくあることだと思います。

ところが、UITextViewにはプレースホルダーが存在しません。したがって、UITextFieldのように placeholder にStringを入れればよい、というように簡単にはいきません。 本記事ではプレースホルダー付きのUITextViewの作成方法を記載します。

環境設定

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

  • Xcode10.2.1
  • Swift 5.0.1

方法

どういう風に実現するのが良いかと考えましたが、最終的にはUITextViewの上にUILabelを置くことにしました。 以下、作成したカスタムクラスのコードを記載します。

概要としては、UILabelをtextContainerのinsetやlineFragmentPaddingを考慮しながら配置、 編集開始と編集終了のイベントをKVOで受け取り、必要に応じてプレースホルダーの表示・非表示を制御しています。 プレースホルダーの文字列はプレースホルダープロパティに設定するのみで表示されるように作成しています。

import Foundation
import UIKit

@IBDesignable
public final class PlaceHolderTextView: UITextView {
    
    // MARK: - IBInspectable
    
    @IBInspectable
    public var cornerRadius: CGFloat {
        set { layer.cornerRadius = newValue }
        get { return layer.cornerRadius }
    }
    
    @IBInspectable
    public var borderColor: UIColor? {
        set { layer.borderColor = newValue?.cgColor }
        get {
            if let cgColor = layer.borderColor {
                return UIColor(cgColor: cgColor)
            } else {
                return nil
            }
        }
    }
    
    @IBInspectable
    public var borderWidth: CGFloat {
        set { layer.borderWidth = newValue }
        get { return layer.borderWidth }
    }
    
    /// プレースホルダー表示用の文字列
    /// 本プロパティの値がプレースホルダーとして表示される
    @IBInspectable
    public var placeHolder: String? {
        didSet {
            placeHolderLabel.text = placeHolder
        }
    }
    
    /// 本来持っているtextプロパティを上書きして、didSetをつけている
    /// このようにすることで、UITextViewの初期値に
    /// textが入っていた際にプレースホルダーを非表示にできる
    public override var text: String! {
        didSet {
            placeHolderLabel.isHidden = !text.isEmpty
            placeHolderLabel.sizeToFit()
        }
    }
    
    private let placeHolderLabel = UILabel(frame: .zero)
    
    // MARK: - Life Cycle
    
    public override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonInit()
    }
        
    required public init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    override public func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        commonInit()
    }
    
    deinit {
        NotificationCenter.default.removeObserver(self, name: UITextView.textDidBeginEditingNotification, object: nil)
        NotificationCenter.default.removeObserver(self, name: UITextView.textDidEndEditingNotification, object: nil)
    }
}

private extension PlaceHolderTextView {
    
    private func commonInit() {
        
        // PlaceHolder表示用のラベルをUITextViewに加えConstraintをつける
        layer.masksToBounds = true
        addSubview(placeHolderLabel)
        
        // TextViewのtextと位置が同じになるように、
        // textContainerInsetとlineFragmentPaddingを考慮してConstraintをかける
        let padding = textContainer.lineFragmentPadding
        placeHolderLabel.translatesAutoresizingMaskIntoConstraints = false

        placeHolderLabel.topAnchor.constraint(equalTo: topAnchor
            , constant: textContainerInset.top).isActive = true
        placeHolderLabel.leadingAnchor.constraint(equalTo: leadingAnchor
            , constant: textContainerInset.left + padding).isActive = true
        placeHolderLabel.bottomAnchor.constraint(equalTo:  bottomAnchor
            , constant: textContainerInset.bottom).isActive = true
        placeHolderLabel.trailingAnchor.constraint(equalTo: trailingAnchor
            , constant: textContainerInset.right + padding).isActive = true
        
        let widthConstant = (textContainerInset.left + textContainerInset.right + padding * 2)
        placeHolderLabel.widthAnchor.constraint(equalTo: widthAnchor
            , constant: -widthConstant).isActive = true
        
        // Fontカラーはgrayにしていますが、必要に応じて変更してください
        placeHolderLabel.font = font
        placeHolderLabel.textColor = UIColor.gray
        placeHolderLabel.numberOfLines = 0
        placeHolderLabel.text = placeHolder

        // Observations
        // UITextViewの編集イベントを受け取るためにKVOの設定をしています
        // UITextViewの編集を始めるとプレースホルダーが消え、
        // 編集完了すると、textの中身をみてプレースホルダーを表示するか判断します
        NotificationCenter.default.addObserver(self, selector: #selector(hidePlaceHolder), name: UITextView.textDidBeginEditingNotification, object: nil)
        
        NotificationCenter.default.addObserver(self, selector: #selector(changePlaceholderVisibility), name: UITextView.textDidEndEditingNotification, object: nil)
    }
    
    @objc func hidePlaceHolder() {
        placeHolderLabel.isHidden = true
    }
    
    @objc func changePlaceholderVisibility() {
        placeHolderLabel.isHidden = !text.isEmpty
    }
}

以上で下記のようなプレースホルダー付きのUITextViewが簡単に使用できます。

f:id:Iganin:20190622220151p:plain
PlaceHolderTextView(プレースホルダー表示)
f:id:Iganin:20190622220219p:plain
PlaceHolderTextView(プレースホルダー非表示)

まとめ

プレースホルダー付きのUITextViewの作成方法をご紹介しました。

要件として上がってきた際にはぜひ使用してみてください。

参考

【iOS】単位付きのUITextFieldを作る方法とカスタムクラス

はじめに

ユーザーからの情報の入力の際にTextViewと並んでUITextFieldはよく使われます。 単位付きの入力にしたい場合は、UITextFieldの範囲外にUILabelを設置する、 入力値に単位をつけ、それを特別扱いするようDelegateメソッド内で 対応するといった方法がありますが、今ひとつ実装が面倒なのが正直なところです。

本記事では単位付きのUITextFieldを簡単に作成する方法とカスタムクラスのご紹介をします。

環境設定

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

  • Xcode10.2.1
  • Swift 5.0.1

方法

UITextFieldには、leftView, leftViewMode, rightView, rightViewModeというプロパティがあります。 それぞれUITextFieldの左側のView, 右側に設置するViewおよびそれらをどのように表示するかを指定できます。

なお、ViewModeには以下の設定値があります。

    public enum ViewMode : Int {
        case never // 出さない
        case whileEditing // 編集中のみ出す
        case unlessEditing // 編集中意外に出す
        case always // 常時出す
    }

したがって、leftView, rightViewにUILabelを設定し、適切なViewModeを設定することでUITextFieldに簡単に単位をつけることができます。 なお、leftView, rightViewはUIViewなので、それを継承したUIImageViewなども入れられるはずです。(試してはいません)

カスタムしたUITextField

アプリ作成時に簡単に使用できるように、単位を簡単につけられるUITextFieldを作成しました。

import Foundation
import UIKit

@IBDesignable
public final class WithUnitTextField: UITextField {
    
    @IBInspectable
    public var prefix: String?
    
    @IBInspectable
    public var suffix: String?
    
    public override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    public override func awakeFromNib() {
        super.awakeFromNib()
        commonInit()
    }

    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
      
    public override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        commonInit()
    }
}

private extension WithUnitTextField {

    func commonInit() {
        // ViewMode常時出す設定にしているが必要に応じて変更すること
        // IBInspectableで設定できるようにしてもいいかもしれない
        
        let leftLabel = UILabel(frame: .zero)
        leftLabel.font = font
        leftLabel.text = prefix
        leftLabel.sizeToFit()
        leftView = leftLabel
        leftViewMode = .always
        
        // FontはUITextFieldのFontと同様にしているが、
        // こちらもIBInspectableで設定できるようにしてもいいかもしれない
        
        let rightLabel = UILabel(frame: .zero)
        rightLabel.font = font
        rightLabel.text = suffix
        rightLabel.sizeToFit()
        rightView = rightLabel
        rightViewMode = .always
    }
    
}

例えばですが、下記画像のようなTextFieldがInterface Builder上のみで簡単に作成できます。

f:id:Iganin:20190620003258p:plain

まとめ

単位付きのUITextFieldを作成する方法と、それを活かしたカスタムクラスのコードをご紹介しました。 要件として求められた際にはぜひ選択肢の一つとしてご検討ください。

参考

【iOS】SwiftGenで自動生成されるファイル内のStructやenumのアクセス修飾子をpublicにする方法

はじめに

SwiftGenはR.swift同様にAssetやImage、Storyboardへのアクセスを、安全にしてくれるStructやenumなどの自動生成ツールです。 例えば、UIImageのインスタンスの生成はSwiftGenを使用すると下記のようになり、タイポによる不具合を防ぐことができます。

// 前
let image = UIImage(named: "sample")

// 後
let image = Asset.sample.image

SwiftGenの設定方法等は他にも記事が上がっていますので、そちらをご参照ください。 本記事ではSwiftGenによって自動生成されるstructやenumをpublicアクセスにする方法を記載します。

環境設定

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

  • Xcode10.2.1
  • Swift 5.0.1

なぜpublicにしたいか

SwiftGenによって自動生成されるstructやenumのデフォルトのアクセス修飾子はinternalになっています。

この場合、マルチモジュール構成で画像やColor AssetをUIモジュールに分割していたり、Resourceと判断してそれ用のモジュールに分割していた場合、 そのモジュール外からでは自動生成されたstructやenumにアクセスできません。そこでアクセス修飾子をpublicにしたいというモチベーションが生まれます。

方法

SwiftGenを使用する際には各種設定をswiftgen.ymlファイルに記載しますが、こちらに修正を加えることでpublic accessに変更できます。

xcassets:
  inputs: UIComponent/Assets.xcassets
  outputs:
    templateName: swift4
    output: UIComponent/Generated/DefaultAsset.swift
    params: # ここで設定を行っています
      publicAccess: true

上記の設定内の params -> publicAccess: true で設定を行っています。これでアクセス修飾子を変更できます。

まとめ

SwiftGenを使用した際のアクセス修飾子の変更方法を記載しました。 publicAccess: trueにすれば良いことはわかりましたが、 paramsで設定するという情報になかなかたどり付けなかったため、まとめることにしました。

publicAccess以外にもparamsを使用することでtemplateの設定を上書きし、自動生成されるコードの内容を変えられるようです。ぜひ試してみてください。

参考

【iOS】UITabBarの真ん中のボタンが特殊な見た目・挙動のUIを作成する方法

はじめに

InstagramのようにTabBarの真ん中が通常のTabBarItemではなく、押下することでModalで画面が表示されたり、 シートで選択肢が表示されるようなアプリがあります。また真ん中のボタンのデザインがTabBarからはみ出したりしているようなものもあります。このようなUIを作成する方法に関して記載します。

f:id:Iganin:20190616174521p:plain
Instagramサンプル画像

環境設定

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

  • Xcode10.2.1
  • Swift 5.0.1

方法

おそらく一番簡単な方法は真ん中にタップ不可能なTabを追加し、その上にボタンを配置する方法です。 まずはTabBarItemの生成部分を見てみます。ここで注意すべき点は以下です。

  • 真ん中にImage, SelectedImage, titleが全てないUITabBarItemを追加する
  • 真ん中のUITabBarItemのisEnableをfalseにする

このようにすることでTabBarの真ん中にボタン配置用のスペースができ、誤って真ん中のタブを選択されるようなことを防げます。

// タブ情報定義部分
    enum TabType: Int, CaseIterable {
        case pickup
        case news
        case empty
        case friends
        case settings

        var data: (title: String, image: UIImage?, selectedImage: UIImage?) {
            switch self {
            case .pickup :
                return (title: "ピックアップ", image: UIImage(named: "ic_pickup"), selectedImage: UIImage(named: "ic_pickup"))
            case .news:
                return (title: "ニュース", image: UIImage(named: "ic_news"), selectedImage: UIImage(named: "ic_news"))
            case .empty:
                return (title: "", image: nil, selectedImage: nil)
            case .friends:
                return (title: "ともだち", image: UIImage(named: "ic_friends"), selectedImage: UIImage(named: "ic_friends"))
            case .settings:
            return (title: "設定", image: UIImage(named: "ic_setting"), selectedImage: UIImage(named: "ic_setting"))
            }
        }
    }


// タブ生成部分
        var addingViewControllers = [UIViewController]()
        
        TabType.allCases.forEach { type in
            var viewController: UIViewController
            switch type {
            case .pickup: viewController = UIViewController(nibName: nil, bundle: nil)
            case .news: viewController = UIViewController(nibName: nil, bundle: nil)
            case .empty: viewController = UIViewController(nibName: nil, bundle: nil)
            case .friends: viewController = UIViewController(nibName: nil, bundle: nil)
            case .settings: viewController = UIViewController(nibName: nil, bundle: nil)
            }
            let navigationController = UINavigationController(rootViewController: viewController)
            let tabBarItem = UITabBarItem(title: type.data.title, image: type.data.image, selectedImage: type.data.selectedImage)
            if case .empty = type { tabBarItem.isEnabled = false } // 真ん中のタブはさわれない
            navigationController.tabBarItem = tabBarItem
            addingViewControllers.append(navigationController)
        }
        setViewControllers(addingViewControllers, animated: false)

次にTabBarの真ん中のボタン生成・配置をみていきます。 特に特別なことはしていなく、Buttonを作成、Propertyとして保持、Viewに追加、viewWillLayoutSubviewsにてレイアウトの調整を行っています。

AutoLayoutで配置の調整を試みましたがうまくいかなかったため、FrameLayoutで行っています。このあたり良い方法をご存知の方がいましたら共有いただけますと嬉しいです。

    // MARK: Property
    private var centerButton: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
        addCenterButton()
    }

    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        centerButton.center = tabBar.center
        centerButton.frame.origin.y = tabBar.frame.origin.y
    }

    func addCenterButton() {
        let centerButton = UIButton(type: .custom)
        centerButton.setBackgroundImage(R.image.white_circle()!, for: .normal)
        centerButton.setImage(R.image.ic_camera()!, for: .normal)
        centerButton.addTarget(self, action: #selector(didTapCenterButton(_:)), for: .touchUpInside)
        centerButton.frame = CGRect(x: 0.0, y: 0.0, width: 50.0, height: 50.0)
        self.centerButton = centerButton
        view.addSubview(centerButton)
    }

    // MARK: Button Action
    @objc func didTapCenterButton(_ sender: UIButton) {
        // 省略
    }

以上で以下のようなTabBarが作成できます。 真ん中のボタンを押下した際の挙動は @objc func didTapCenterButton(_ sender: UIButton) に記載します。

f:id:Iganin:20190616175408p:plain
TabBarサンプル

まとめ

TabBarの真ん中に通常のTabBarとは違う挙動をするボタンを追加する方法を記載しました。 アプリ開発をするなかで要件として出てきた際にはぜひ活用してみてください。 なお、本記事ではコードベースでの生成方法を記載していますが、StoryboardにてTabBarを配置しそちらでレイアウト調整等するのも方法としてありだと思います。

参考