チンチラのフンみたいなもの

毎日の学んだことを書いていきます

アプリリリースしました!!!!③

前回 

アプリに使った技術 地図編

 地図にはGoogle Maps SDKのライブラリを使っています。なぜMapKitではなくGoogleMapを使うのかと言うと、ストリートビューを使うことができるからです。訪問営業アプリなので、現在地やデフォルメされた地図だけでなく、実際に写真で見れた方が圧倒的に分かりやすいだろうと思っていました。なので私の中でストリートビューは割と必須項目でした。でも私のアプリにはストリートビューがありません。技術不足でした。。。GoogleMapのようにしたかったので、左下にUIViewを置いて、そこにストリートビューをaddSubViewして表示するところまではできました。しかし、そのビューの中でずっとぐるぐるとロード中のマークが回ってました。ロードが終わるまではビューをタッチしても何も反応しないようになってました。この問題の解決策が分からず、ストリートビューはアプリの必要条件ではないということでお見送りさせて頂きました。ただこのことはアプリ開発のかなり初期にあったものなので、今の私ならイケるのじゃないかと謎の自身があります。また今度挑戦してみます。
 次に現在地の取得の話です。現在地を取得してもいいかどうかはiPhoneの設定のアプリ一覧から設定することができます。ここで面白いのが現在地の取得の許可の他に、正確な位置情報という欄があります。これをぜひお手元の「セールスマップ」か地図アプリで試して欲しいのです。オンの時はいつもの状態ですが、オフの時はぶわっと円が広がり、現在地も半径数キロメートル以内というかなり大雑把なものになります。このアプリ開発を通じて初めて知ったのですが、アプリの設定がこんな風に反映されるんだと少し感動した覚えがあります。Google Maps SDKのチュートリアルの中にpreciseLocationZoomLevelとapproximateLocationZoomLevelというかなり長い変数があると思います。これはこの正確な位置情報がオンの時とオフの時のカメラのズームレベルを表しています。要は円全体が見えるようにカメラを動かすというものです。訪問営業記録アプリ「セールスマップ」の場合、家が正確に分かるまで地図を拡大する必要があるのでpreciseLocationZoomLevelの値が高めに設定されています。
 チュートリアルではカメラの動きを、CLLocationManagerDelegateという位置情報を取得した時に通知するデリゲートの中で全部設定しているのですが、実はこれ結構罠です。CLLocationManagerDelegateが通知するタイミングが最初に現在地を取得した時、何メートルか動いた時の他にアプリがバックグラウンドからフォアグラウンドになった時があります。これがかなりうっとおしいです。現在地と異なる場所に地図のカメラがあり、そこからちょっと調べようと思って他のアプリに行って、戻ってくると現在地に戻ります。これでは現在地の記録以外させませんと言っているようなものです。これはいけないと思い、無理やりな感じですがisFirst変数を使って最初の起動時に現在地を取得したらfalseにするようにしています。この起動時のみカメラが動くという設定自体はGoogleMapと多分同じです。
 最後に地図アプリを申請する際はぜひやって欲しいことを説明します。「セールスマップ」もしくはAppleマップの位置情報の取得の許可を許可しないに変えてみて下さい。そうすると、上のNavigationBarに「位置情報サービスがオフです>」というボタンが現れます。これを押すとアプリの設定に遷移して、位置情報の取得の許可を促すのですが、このボタンをぜひ設定して欲しいです。私は当初このボタンを設定しないままアプリを申請しました。するとAppleから「ガイドライン4.3に反しているためリジェクトする」とメールが来ました。詳しくはこの記事とかこの記事を読んで欲しいのですが、要は他のアプリと同じだからスパム扱いになっているというものです。私はこれに対して何度かメールで抗議しましたが、全く態度が変わる感じがありませんでした。もしかしたら、このアプリは起動時に位置情報を取得しなかった場合日本地図が表示されるように設定されているので、ただ日本地図を表示するアプリだと勘違いしているのではと考えました。そこでそのボタンを作って提出した所見事「ガイドライン4.3問題」は解消されました。位置情報ぐらいONにしろよと思ってしまいますが、そのせいで操作方法が分からなくなるユーザーもいるということを学ぶいいきっかけになったかなと思います。
次回 

アプリリリースしました!!!!②

前回 

アプリのコンセプト

 そこで「現在地が分かる地図」、「訪問した家に目印をつける」、「訪問結果を記録する」の3つを1つのツールでできるようにしようと思ったのが、今回のアプリの始まりです。今思うとこの3つの機能をもったアプリは他にもあります。当時そのことを知っていたらこのアプリを作ろうとはならなかったかもしれません。
 他の地図アプリと差別化する為にこだわった部分は「訪問結果を記録する」の部分です。他のアプリと違い、「訪問」、「約束」、「契約」、「解約」と言う4つのアイコンがあります。このアイコンを使えばその4つの事についてボタンを押すだけで記録ができます。これならば路上で記録する必要があっても結果だけなら簡単に記録できます。落ち着いてからその記録について詳細を書きたければ、編集することも可能です。
 アイコンの役割は他にアイコンを押した累計数を表すことです。これにより簡易的なKPIを表すことができます。これが果たしてKPIと呼べるのかどうか微妙ですが、「訪問」の数に対して「約束」の数が少なければ最初の交渉の部分に、「約束」の数に対して「契約」の数が少なければ最後の詰めに、「契約」の数に対して「解約」の数が多ければアフターフォローに問題があると簡単な分析はできると思います。「訪問」については最初からONにしとけよと思われるかもしれませんが、これは電話営業をイメージしているためです。電話営業なら「訪問」にはなりませんが、他のアイコンには繋がる可能性があります。
 そして先述のような記録をアイコンにした理由は、楽しいを感じて欲しかったからです。仕事中にちょっと可愛げのあるアイコンを押すことは少ないと思います。アナログな会社だと尚更。なので仕事中にスマホで可愛いアイコンをタッチすると言う体験を通じて、ちょっとでも毎日の仕事が楽しくなればと思い採用しました。

次回予告

 明日は「アプリの開発の流れ」、「アプリに使った技術」、「本当はアプリに採用したかった機能」のいずれかについて書きます!お楽しみに!
 訪問営業記録アプリ「セールスマップ」はコチラから!

次回 

アプリリリースしました!!!!①

アプリを作った背景

 セールスマップと言うアプリを作りました!これは訪問営業の記録をするためのアプリです。前職は金融系の仕事に勤めており、私は基本的に事務でしたが、時々営業もさせられることもあり、その時に感じた不満を解決するために作ったアプリです。
 前職の仕事は非常にアナログでした。何をするにしても紙とハンコが必要な前時代的な仕事ばかりで、周りもそれに対して不満を持つものの何も変えることはできず、そのまま我慢してずっと同じやり方を10年、20年と続けているような所でした。訪問営業もその一つでした。まず担当地区を決めるのですが、印刷した地図を切り分けて配って、受け取った地図の所が担当地区です。思わずスマホの地図を使えよと思ってしまいます。その地図を見ながらお家に訪問するのですが、当然ながら地図には現在地は書かれていません。今自分はどこにいるのか、どの家に訪問しているのかを把握するためにはスマホの地図を見るか、周りの建物の感じから予測するしかありません。
 また訪問していない家がないようにどの家を訪問したか記録する必要があります。その記録はグーグルマップやアップルマップではできません(多分)。スマホでスマートに現在地を把握していたとしても、訪問した家にチェックを入れるために結局紙の地図に戻ることになります。さらに訪問した結果どうだったのかも記録する必要があります。紙の地図にはそれを記録するスペースはありません。その記録をするためにはスマホのメモアプリを使います。路上で歩きながら色んなツールを行ったり来たりするする必要があるので、とてもじゃないですが営業そのものに集中できませんでした。

次回 

Apple・Googleそれぞれの逆ジオコーディング

逆ジオコーディングとは

ジーコーディングとは住所から経度緯度のような位置情報に変換することである。逆ジオコーディングはその逆なので、緯度経度から人間が読める住所に変換することである。AppleGoogleはそれぞれAppleマップ、Googleマップと自社が提供する地図アプリがある。そのこともあってかどちらにも位置情報のライブラリがある。そのライブラリには逆ジオコーディングをするためのメソッドがあるので、今回はそれを使っていく。

AppleGoogleの違い

Appleが提供するメソッド

func reverseGeocodeLocation(_ location: CLLocation, 
          completionHandler: @escaping CLGeocodeCompletionHandler)

Googleが提供するメソッド

func reverseGeocodeCoordinate(_ coordinate: CLLocationCoordinate2D, completionHandler handler: @escaping GMSReverseGeocodeCallback)

どちらも緯度経度をcompletionHandlerに渡してエラーがなければ住所が返ってくると言う形は変わりません。しかし、その返ってくる情報にはかなり違いがあります。実際に使いながら見てみましょう。

Apple

import CoreLocation
import Contacts
let gecoder = CLGeocoder()
gecoder.reverseGeocodeLocation(CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude), completionHandler: {(placemark, err) in
 guard let placemark = placemark?.first else { return }
    print(placemark.postalCode)   //768-0002
    print(placemark.administrativeArea) //香川県
    print(placemark.locality)   //観音寺市
    print(placemark.thoroughfare) //高屋町
    print(placemark.subLocality)   //高屋町
    print(placemark.subThoroughfare)   //2800
    print(placemark.name)   //天空の鳥居
    print(placemark.subAdministrativeArea)   //nil
    print(placemark.postalAddress)   //<CNPostalAddress: 0x281f0ba20: street=高屋町2800, subLocality=高屋町, city=観音寺市, subAdministrativeArea=, state=香川県, postalCode=768-0002, country=日本, countryCode=JP>
})

Google

import GoogleMaps
let geoCoder = GMSGeocoder()
geoCoder.reverseGeocodeCoordinate(coordinate, completionHandler: {(placemarks,err) in
    guard let placemark = placemarks?.firstResult() else { return }
    print(placemark.postalCode)   //768-0001
    print(placemark.administrativeArea)   //香川県
    print(placemark.locality)   //観音寺市
    print(placemark.subLocality)   //nil
    print(placemark.thoroughfare)   //nil
    print(placemark.lines?.first)   //日本、〒768-0001 香川県観音寺市室本町2731
})

住所の情報をもつクラスのプロパティをprint文で表示させました。print文の右側にコメントで実行結果を書きました。実行結果は私の地元である香川の観光地「天空の鳥居」をタッチした時のものです。Appleの方は簡単に県、市、町、番地と細かく分けることができるのが特徴みたいです。Googleの方は元々プロパティの数は少ないため細かく分けることはできないが、lines?.firstで非常に綺麗にフォーマットされた住所を簡単に取得できることが特徴みたいです。
それなら正直どっちでもいいじゃんとなるのですが、両者には無視できない違いがあります。まずお気づきだと思いますが、表示されている住所が違います。Appleは「〒768-0002 香川県観音寺市室本町2800」なの対し、Googleは「〒768-0001 香川県観音寺市室本町2731」です。正解はAppleの方です。GoogleMapのアプリで見ると「〒768-0002 香川県観音寺市室本町2800」となっています。どういうことなのでしょうか?またGoogleは市までしか取得できないが、Appleなら町やその場所の名前まで取得できたりする場所があります。一方でAppleGoogleの方なら番地まで表示されるのにAppleの方は表示されないという場所もあります。
色んな場所をタッチして確認して見たところ両者の情報量は、町のGoogle、山のAppleという印象です。Appleは町の中で番地まで表示できないところがちょこちょこあります。一方Googleは山の中となると市までしか取得できないことが結構あります。上の間違った住所を表示した「天空の鳥居」も山の中なのでちょっとおかしくなったのかもしれません。

まとめ

結論としては逆ジオコーディングをする際は、アプリの用途によってAppleGoogleかを選択するか、高度によって使い分けて両者のいいとこ取りをするのが良いでしょうか。この問題については私のコードや導入方法に問題がある為起きてしまっている可能性も十分にあります。何か分かる方いましたら教えて頂けると助かります。

Google Maps SDK でエラー"This app is not allowed to query for scheme comgooglemaps.Even though i have added "comgooglemaps"

googlemapの左下にあるGoogleのロゴを押したら出た

GoogleMapへ遷移するための設定が上手く出来てなかったみたい。ここに設定の仕方が書いてある。

https://developers.google.com/maps/documentation/ios-sdk/config

具体的なやり方

  1. infoファイルを開く
  2. infomation Property Listの欄の右側の+ボタンを押す
  3. 行が追加されるので
LSApplicationQueriesSchemes

を入力する。

  1. TypeをAnyにする
  2. 左側に三角が現れるので押して下向きにする
  3. その状態で+ボタンを2回押して
googlechromes
comgooglemaps

を入力する

ライブラリ「Instructions」で[ERROR] The overlay view added to the window has empty bounds, Instructions will stop.が出た

Instructions」はチュートリアル画面を簡単に作れるようになると言うライブラリである。そのライブラリのREADMEのチュートリアルに従って書いたが、なぜかエラーになる。

import UIKit
import Instructions

class ViewController: UIViewController {

    @IBOutlet var textFieldCollection: [UITextField]!
    @IBOutlet weak var sumResultLabel: UILabel!
    let coachMarksController = CoachMarksController()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.coachMarksController.dataSource = self
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        self.coachMarksController.start(in: .window(over: self))
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        self.coachMarksController.stop(immediately: true)
    }

    @IBAction func tappedSumButtom(_ sender: Any) {
        sumResultLabel.text = String(textFieldCollection.compactMap{Int($0.text!)}.reduce(0,+)
    }
    
}

extension ViewController:CoachMarksControllerDataSource,CoachMarksControllerDelegate{
    func numberOfCoachMarks(for coachMarksController: CoachMarksController) -> Int {
        return 1
    }
    
    func coachMarksController(_ coachMarksController: CoachMarksController,
                              coachMarkAt index: Int) -> CoachMark {
        return coachMarksController.helper.makeCoachMark(for: sumResultLabel)
    }
    
    func coachMarksController(
        _ coachMarksController: CoachMarksController,
        coachMarkViewsAt index: Int,
        madeFrom coachMark: CoachMark
    ) -> (bodyView: UIView & CoachMarkBodyView, arrowView: (UIView & CoachMarkArrowView)?) {
        let coachViews = coachMarksController.helper.makeDefaultCoachViews(
            withArrow: true,
            arrowOrientation: coachMark.arrowOrientation
        )

        coachViews.bodyView.hintLabel.text = "Hello! I'm a Coach Mark!"
        coachViews.bodyView.nextLabel.text = "Ok!"

        return (bodyView: coachViews.bodyView, arrowView: coachViews.arrowView)
    }
    
}

このコードはアプリ道場サロンの課題で書いたものにお試しでInstructionsを当てはめたもの。エラーの文章には「[ERROR] The overlay view added to the window has empty bounds, Instructions will stop.とある。日本語にすると「ウィンドウに追加されたオーバーレイビューの境界が空であるため、命令が停止します。」イマイチよくわからん。このエラーメッセージはこのライブラリで定義されているものなので、エラーメッセージで検索して定義を見てみる。

// The delegate might have paused the flow, we check whether or not it's
        // the case.
        if !self.isPaused {
            if coachMarksViewController.instructionsRootView.bounds.isEmpty {
                print(ErrorMessage.Error.overlayEmptyBounds)
                self.stopFlow()
                return
            }

            coachMarksViewController.show(coachMark: &currentCoachMark!, at: currentIndex) {
                self.canShowCoachMark = true

                self.delegate?.didShow(coachMark: self.currentCoachMark!,
                                       afterChanging: change, at: self.currentIndex)
            }
        }

この中のoverlayEmptyBoundsがこのエラーメッセージを定義つけている定数。なのでこの部分で引っかかってデバッグエリアにエラーメッセージが出ている。見た感じだとコーチマークの描写がそもそもできて無さそうなのでcoachMarksViewControllerがそもそもインスタンス化できていないのではと思った。そこでブレークポイントを使って中身を見てみたが普通に出来てた。

原因がわからず、READMEをもう一度読み直しているとあることに気づく。僕のコードだとviewWillAppearに書いている部分をREADMEではviewDidAppearに書いているということを・・・・!僕がviewWillAppearに書いたのは理由があります。それはREADMEのこの部分

Be careful, you can't call start in the viewDidLoad method, since the view hierarchy has to be set up and ready for Instructions to work properly.

viewDidAppearとしか書いてないもん・・・。ならの次のviewWillAppearに書こうとなるのが普通の発想だと思うが、viewWillAppearじゃダメだった。READMEではviewDidAppearがきちんとが使われいた。結局上のコードも

override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        self.coachMarksController.start(in: .window(over: self))
    }

override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        self.coachMarksController.start(in: .window(over: self))
    }

に直したら普通に動いた😅 ちょっとREADMEに問題があるじゃねえの?と思うのでisseで問い合わせてみたいと思います!この件については次回!

記念すべき初投稿

Notionにまとめていたノートをそのまま持ってきました。3日分くらいの内容です。AppDelegateとかの話が分からなすぎて調べまくったものの、数日たったらめちゃくちゃ忘れてて絶望したのでメモとることにしました。またNotionでまとめるだけだと自分にしか見えず、何もやってない人と思われるが嫌なのでブログで発信していくことにしました。草生やさなくても勉強はしてるんだぞと言う証明になれば。

これから毎日Notionでまとめたノートをコピペして記事にしようと思います。web上でチマチマ下書き記事を更新していくよりは、アプリでぱぱっとメモった方が楽だと思うのでそうします。Notionのいいところはコピペするとその時に使ったマークダウンがそのまま反映されるところです。なのでこの記事もめちゃくちゃ読みにくいと言うものにはなっていないと思います。まあそれでも修正とかはしていないので読みづらいとは思います。なのでそういう記事には「無修正」と言うちょっとエッチなタグをつけますので、その記事を読みたくない場合は「無修正」タグを避けて頂ければと思います。これからよろしくお願いします。

  • 中に配列を持たないarray型を一つの文字列にするjoinedメソッド(参考参考
let list = ["1", "2", "3"]
let string1 = list.joined(separator: "")
let string2 = list.joined(separator: "-")
//separatorで間に何を入れてくぎるかを指定できます。
print(string1) // "123"
print(string2) // "1-2-3
  • CustomStringConvertibleに準拠していると、インスタンス化するだけでprint文がdescriptionプロパティを参照するようになる(参考
struct Checkerboard {
  enum Square: String {
    case empty = "▪️"
    case red = "🔴"
    case white = "⚪️"
  }

  typealias Coordinate = (x: Int, y: Int)

  private var squares: [[Square]] = [
    [ .empty, .red,   .empty, .red,   .empty, .red,   .empty, .red   ],
    [ .red,   .empty, .red,   .empty, .red,   .empty, .red,   .empty ],
    [ .empty, .red,   .empty, .red,   .empty, .red,   .empty, .red   ],
    [ .empty, .empty, .empty, .empty, .empty, .empty, .empty, .empty ],
    [ .empty, .empty, .empty, .empty, .empty, .empty, .empty, .empty ],
    [ .white, .empty, .white, .empty, .white, .empty, .white, .empty ],
    [ .empty, .white, .empty, .white, .empty, .white, .empty, .white ],
    [ .white, .empty, .white, .empty, .white, .empty, .white, .empty ]
  ]
}

extension Checkerboard: CustomStringConvertible {
  var description: String {
    return squares.map { row in row.map { $0.rawValue }.joined(separator: "") }
        .joined(separator: "\n") + "\n"
  }
}
  • substructを使うと配列や辞書でなくても添字が使えるようになる。処理としてはコンピューテッドプロパティと同じだが、引数が使えることと、特定の場面で可読性が向上することが特徴(参考参考
subscript(coordinate: Coordinate) -> Square {
  get {
    return squares[coordinate.y][coordinate.x]
  }
  set {
    squares[coordinate.y][coordinate.x] = newValue
  }
}
  • RootViewControllerの入れ替え

SceneDelegate.swiftってなに? - Qiita

[Swift 5]RootViewControllerを適用する備忘録 - Qiita

iOSのrootViewControllerを置き換える - Qiita

  • addChild(_:)  現在のビューコントローラーに子ビューコントローラーを追加する。子ビューコントローラーが既にコンテナビューコントローラーである時、追加される前にそのコンテナビューから削除される。ここで言うコンテナビューとはコントローラービューをまとめて移動処理等を管理するコントローラーのことを指すと思われる。またaddChildを呼ぶさいselfがいらないのは呼ぶのが現在のビューコントローラーであるからだと思われる。
  • didMove(toParent:)  独自のコンテナービューで新しいコントローラービューへの移行が完了した場合、または移動しない場合にはこのメソッドを呼ぶ必要がある。removeFromParen()は子を削除した後に子ビューコントローラーのdidMove()メソッドを自動的に呼ぶ
  • willMove(toParent:)  独自のコンテナビューでremoveFromParentメソッドを呼び出す前にwillMove()メソッドを呼びだし、親の値にnilを返さなければならない。addChild()メソッドを呼び出すと子ビューコントローラーのwillMove()メソッドを自動できに呼ぶ。
  • removeFromSurperView()  ビューをそのスーパービューとウインドウから削除する。
  • removeFromParent()  親ビューコントローラから子ビューコントローラーを削除する。コンテナビューからのみ呼ばれる。
  • addSubview(_:)  viewのサブビューのリストの末尾に追加する。呼び出すのはあくまでview

LaunchScreenの表示時間の変更

【Swift】LaunchScreenの待機時間を設定する方法 - Qiita

チュートリアル画面ライブラリInstructionsの使い方

https://github.com/ephread/Instructions

1.コーチマークを表示させるコントローラーでCoachMarksControllerクラスのインスタンス化とプロトコルの準拠させる

class DefaultViewController: UIViewController,
                             CoachMarksControllerDataSource,
                             CoachMarksControllerDelegate {
    let coachMarksController = CoachMarksController()

    override func viewDidLoad() {
        super.viewDidLoad()

        self.coachMarksController.dataSource = self
    }
}

2.CoachMarksControllerDataSourceプロトコルに準拠すると3つのメソッドを実装する必要がある。1つは表示するコーチマークの数を問うもの。

func numberOfCoachMarks(for coachMarksController: CoachMarksController) -> Int {
    return 1
}

2つ目はメタデータを要求するもの。コーチマークの位置や表示方法をカスタマイズできるが、見た目は変更できない。coachMarkAtはindexPathのようなもの

let pointOfInterest = UIView()

func coachMarksController(_ coachMarksController: CoachMarksController,
                          coachMarkAt index: Int) -> CoachMark {
    return coachMarksController.helper.makeCoachMark(for: pointOfInterest)
}

3つ目は2つのビュー(cellForRowAtIndexPathのようなもの)をタプルの形で提供する。bodyViewはコーチマークの核となるもので必須だが、arrowViewはオプション

func coachMarksController(
    _ coachMarksController: CoachMarksController,
    coachMarkViewsAt index: Int,
    madeFrom coachMark: CoachMark
) -> (bodyView: UIView & CoachMarkBodyView, arrowView: (UIView & CoachMarkArrowView)?) {
    let coachViews = coachMarksController.helper.makeDefaultCoachViews(
        withArrow: true,
        arrowOrientation: coachMark.arrowOrientation
    )

    coachViews.bodyView.hintLabel.text = "Hello! I'm a Coach Mark!"
    coachViews.bodyView.nextLabel.text = "Ok!"

    return (bodyView: coachViews.bodyView, arrowView: coachViews.arrowView)
}

3.dataSourseのセットアップが完了したら、コーチマークの表示を開始します。ほとんどの場合selfを返して開始。windowの子として自身のビューコントローラを追加し、そのビューコントローラーの子としてCoachMarksControllerを追加する。そうすることで、CoachMarksControllerはサイズ変更イベントを受け取ることができる。またその処理を正しく行うためviewDidLoadでstartメソッドを読んではいけない

override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)

    self.coachMarksController.start(in: .window(over: self))
}

4.ビューが消えたら必ずフローを停止させる必要がある。viewDidDisappearでstop(immediately: true)を呼び出すとビューが消えた時にフローが直ちに停止することが確認できる

override func viewWillDisappear(animated: Bool) {
    super.viewWillDisappear(animated)

    self.coachMarksController.stop(immediately: true)
}

オプション

  • オーバーレイの色変更
  • オーバーレイをタッチすると次のステップにいく
  • 切り出したビューやオーバーレイ全体でのタッチイベントを下のビューに転送できる
  • コーチマークの色、背景色、丸み
  • 矢印の向き
func coachMarksController(
    _ coachMarksController: CoachMarksController,
    coachMarkViewsAt index: Int,
    madeFrom coachMark: CoachMark
) -> (bodyView: UIView & CoachMarkBodyView, arrowView: (UIView & CoachMarkArrowView)?) {
    let coachViews = coachMarksController.helper.makeDefaultCoachViews(
        withArrow: true,
        arrowOrientation: coachMark.arrowOrientation
    )
}
  • 切り出し方の形
var coachMark = coachMarksController.helper.makeCoachMark(
    for: customView,
    cutoutPathMaker: { (frame: CGRect) -> UIBezierPath in
        // This will create an oval cutout a bit larger than the view.
        return UIBezierPath(ovalIn: frame.insetBy(dx: -4, dy: -4))
    }
)
  • どのwindowで表示させるか
  • コーチマークをスキップする手段を提供する。プロトコルに準拠してどのコントロールをタップする必要があるか知らせる
public protocol CoachMarkSkipView: AnyObject {
    var skipControl: UIControl? { get }
}
  • コーチマークが表示される時、消える時、全てのコーチマークが表示された時知らせる
func coachMarksController(
    _ coachMarksController: CoachMarksController,
    willShow coachMark: inout CoachMark,
    at index: Int
)
func coachMarksController(
    _ coachMarksController: CoachMarksController,
    willHide coachMark: CoachMark,
    at index: Int
)
func shouldHandleOverlayTap(
    in coachMarksController: CoachMarksController,
    at index: Int
) -> Bool
  • 一時停止中にオーバーレイを隠すか、オーバーレイを隠してタッチブロック機能だけ残すか