20200209のSwiftに関する記事は11件です。

Share Extensionのハマりポイント

はじめに

初の個人アプリを App Store に審査出したら 30 分足らずでリリースされてビビったからその全てをお伝えしますの記事を見てShare Extensionいいなぁと思い自分のアプリにもつけてみよう!と思ったのですが色々ハマりました...

対象

  • iOS13.0以上
  • macOS Catalina

Share Extensionの実装

ここを参考に実装をしました。

Share Extension の追加自体は簡単でした。

ShareViewController の POST 機能とかいらなかったので参考記事に書いてあったように下記のように修正して画面を作成しました。

ShareViewController.swift
- class ShareViewController: SLComposeServiceViewController {
+ class ShareViewController: UIViewController {

画面は下記のようなものでテキストのみ共有するようにしました。(上下に TextView を置いています。上が共有文字列で下はまだ実装中)

share_ios_screen

共有メニューに表示されない

なぜか共有メニューにアプリが表示されない時がありました:scream:

info.plist
<key>NSExtensionAttributes</key>
<dict>
 <key>NSExtensionActivationRule</key>
 <dict>
  <key>NSExtensionActivationSupportsText</key>
  <true/>
 </dict>
</dict>

上記のようにしてると共有メニューに表示されず参考記事のように SUBQUERY にすると表示されるようになりました:tada:

info.plist
<key>NSExtensionActivationRule</key>
 <string>SUBQUERY (extensionItems,$extensionItem,
  SUBQUERY ($extensionItem.attachments,$attachment,
    (ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" 
    || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text")
  ).@count == $extensionItem.attachments.@count).@count == 1
 </string>

画面が閉じれない

iOS Mac
share_ios_screen share_mac_screen

上記のように iOS 版は半モーダルなので下にスワイプすれば閉じれますが Mac 版の場合は閉じることができません:scream:

こちらも参考記事に閉じる方法が書いてありました:heart_eyes:

閉じるボタンを用意して下記のようにすれば閉じることができました:tada:

@IBAction private func cancel(_ sender: Any) {
  extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
}

共有メニューのローカライズ

iOS Mac
share_ios share_mac

上記のように iOS 版は共有メニューにもアプリのターゲットで設定した Display Name が表示されます(ローカライズも対応)が Mac 版は Share Extension のターゲットで設定した Display Name が表示されています:scream:

Share Extension のターゲットに InfoPlist.strings を下記のように追加しました。

InfoPlist.strings
CFBundleDisplayName = "DNA Converter"; // (Japaneseの方は "DNA変換" と設定)

これで Mac版 でもアプリ同様共有メニューにも日本語表示なら「DNA変換」英語表記なら「DNA Converter」と表示されるようになりました:tada:(どっかに変数を設定してアプリのターゲットと共通化すべきなのかも?)

共有した値が取得できない

致命的なのですが Mac 版で共有した値が取得できませんでした:scream:

共有したテキストの取得処理は下記のように実装しました。

let extensionItem: NSExtensionItem = extensionContext?.inputItems.first as! NSExtensionItem
guard let itemProvider = extensionItem.attachments?.first else {
  // ここでなんかエラー処理
  return
}

let publicText = String(kUTTypeText)  // "public.text"
if itemProvider.hasItemConformingToTypeIdentifier(publicText) {
  itemProvider.loadItem(forTypeIdentifier: publicText, options: nil, completionHandler: { (data, error) in
    guard let text = data as? String else {
      // ここでなんかエラー処理
      return
    }
    // ここでなんか処理
  })
  return
}

let publicPlainText = String(kUTTypePlainText)  // "public.plain-text"
if itemProvider.hasItemConformingToTypeIdentifier(publicPlainText) {
  itemProvider.loadItem(forTypeIdentifier: publicPlainText, options: nil, completionHandler: { (data, error) in
    guard let text = data as? String else {
      // ここでなんかエラー処理
      return
    }
    // ここでなんか処理
  })
  return
}

// ここでなんかエラー処理

iOS では下記のように extensionContext?.inputItems.firstNSExtensionItemAttachmentsKey に値が入っているのですが

 po extensionContext?.inputItems
 Optional<Array<Any>>
   some : 1 element
    - 0 : <NSExtensionItem: 0x6000007e4260> - userInfo: {
    NSExtensionItemAttachmentsKey =     (
        "<NSItemProvider: 0x600002edc690> {types = (\n    \"public.plain-text\"\n)}"
    );
    NSExtensionItemAttributedContentTextKey = {length = 317, bytes = 0x7b5c7274 66315c61 6e73695c 616e7369 ... 20417070 6c65207d };
    "com.apple.UIKit.NSExtensionItemUserInfoIsContentManagedKey" = 0;
}

Mac では下記のように値が入っていません...

po extensionContext?.inputItems
 Optional<Array<Any>>
   some : 1 element
    - 0 : <NSExtensionItem: 0x6000018d45c0> - userInfo: {
    NSExtensionItemAttachmentsKey =     (
    );
    NSExtensionItemAttributedContentTextKey = {length = 315, bytes = 0x7b5c7274 66315c61 6e73695c 616e7369 ... 30204170 706c657d };
}

色々試してみましたが Mac 版では値が取得できませんでした。extensionContext?.inputItems.firstNSExtensionItemAttributedContentTextKey からがんばれば取得できそうですがなんか違うと思いあきらめました。

ここにきて思ったのが Mac 版だけ動作がおかしいのでそもそも Share Extension が Mac に対応してないんじゃね?という疑惑です。(なんか知ってる人いたら教えて下さい)

ということで私は Mac 版の Share Extension はあきらめました:confused:

Mac版で取得できた値(20200211追記)

コメントでURLは取れると聞いたので他の値が取れるかみてみました。

info.plist を下記に設定して extensionContext?.inputItems をログで出してみました。

info.plist
<key>NSExtensionAttributes</key>
<dict>
  <key>NSExtensionActivationRule</key>
  <string>TRUEPREDICATE</string>
</dict>

URL

 Optional<Array<Any>>
   some : 1 element
    - 0 : <NSExtensionItem: 0x6000013e8660> - userInfo: {
    NSExtensionItemAttachmentsKey =     (
        "<NSItemProvider: 0x600003aeddc0> {types = (\n    \"public.url\"\n)}",
        "<NSItemProvider: 0x600003aedea0> {types = (\n    \"public.image\"\n)}"
    );
}

Image

 Optional<Array<Any>>
   some : 1 element
    - 0 : <NSExtensionItem: 0x600002034630> - userInfo: {
    NSExtensionItemAttachmentsKey =     (
        "<NSItemProvider: 0x60000092f3a0> {types = (\n    \"public.image\"\n)}"
    );
}

Movie

 Optional<Array<Any>>
   some : 1 element
    - 0 : <NSExtensionItem: 0x6000013d1ef0> - userInfo: {
    NSExtensionItemAttachmentsKey =     (
        "<NSItemProvider: 0x600003ae0230> {types = (\n    \"com.apple.quicktime-movie\",\n    \"public.file-url\",\n    \"public.url\"\n)}"
    );
}

PDF

 Optional<Array<Any>>
   some : 1 element
    - 0 : <NSExtensionItem: 0x6000013c54f0> - userInfo: {
    NSExtensionItemAttachmentsKey =     (
        "<NSItemProvider: 0x600003ae43f0> {types = (\n    \"public.url\"\n)}",
        "<NSItemProvider: 0x600003ae5ff0> {types = (\n    \"com.adobe.pdf\"\n)}"
    );
}

まとめ

Type 結果
text :x:
url :o:
image :o:
movie :o:
pdf :o:

テキストだけ取れない:poop:

さいごに

Mac 版はなにか特殊な設定がいるんでしょうか?調べた限り有力な情報は得られませんでした...

情報求ム!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ARKit 3のMotion CaptureでVRMを動かすために、関節の回転差分を計算する

前置き

ARKit3では、これまでの表情のキャプチャに加えて全身の姿勢や関節の回転を推定するモーションキャプチャが実装されました。
モーションキャプチャの簡単な解説は、WWDCの「Bringing People into AR」が分かりやすいです。

Bringing People into AR
https://developer.apple.com/videos/play/wwdc2019/607/

ARKit3 のモーションキャプチャでは、91点の関節を認識し3Dアバターの動きと同期することができます。

スクリーンショット 2020-02-09 18.03.09.png

動きを同期する上で一番簡単な方法は、各関節ノードの名前とtransformが指定された値になったアバターを利用することです。
これは公式サンプルの「Capturing Body Motion in 3D」に含まれているrobot.usdzを見ると分かりやすいです。
https://developer.apple.com/documentation/arkit/capturing_body_motion_in_3d

では、自前のモデルなど関節名やtransformの異なるモデルを操るにはどうすれば良いでしょうか?
一つは @nkjzm さんの記事のように、対応する構造にエクスポートする方法があります

ARKit 3のMotion CaptureでVRMを動かす【Unity】
https://qiita.com/nkjzm/items/d4379d5fd018de67a082

そしてもう一つは、上記記事でも触れられていますが各関節の差分から回転を調整して適用する方法です。

今回はVR向け3DアバターファイルフォーマットであるVRMに対して、ARKitで取得した動きを同期する方法を解説します。

ARSkelton3DとVRMの違い

ARSkelton3DはARKitを用いて取得できる人体の構造です。VRMは同じく3Dアバターの構造ですが、両者はいくつかの異なる点が存在します。

骨格数

肩や首などを表す骨格数は

ARSkelton3D VRM
91 54

と異なります。背骨や首が細かく分けられているARSkelton3Dに対してキャラクターを扱うVRMはシンプルな構造になっています

各関節の名称

VRMでは各関節は、自由に名称を付けることができます。
モデルによって右肩一つ取ってもRightSholderやSholderRなど製作者によって名称が異なります。
これらの関節位置と名前はボーンマッピングによって決定されます。

https://github.com/vrm-c/vrm-specification/blob/master/specification/0.0/README.ja.md#vrm拡張-モデルのボーンマッピングjsonextensionsvrmhumanoid

一方のARSkelton3Dは、全ての名称が決まっており次のような文字列で取得することができます。
スクリーンショット 2020-02-09 22.46.38.png

T-Poseの角度

T-Poseは、一般的には両手を水平にあげた状態のことを指します。

image.png

VRMでは、それぞれの関節のtransformがidentity(つまり初期状態)の時に、このT-Poseとなります。

ARSkelton3Dの場合、全ての関節のtransformをidentityにすると次のような体制になります。
image.png
https://forum.unity.com/threads/example-rig-for-3d-human-skeleton.696512/#post-5158382

そして、この体制に

ARSkeletonDefinition.defaultBody3D.neutralBodySkeleton3D

を適用すると、T-Poseになります。
これはつまり、ARSkelton3DがT-Poseの時に各関節がidentityではないということを表しています。

pivotの位置

全体のノードの中心を表すpivotは、VRMの場合は足下が、ARSkelton3Dの場合はhipsが中心になります。

localTransform / modelTransformの違い

ARSkelton3Dからは、各関節のtransformが取得できるのですが、localTransform / modelTransformの2種類を得ることができます。

modelTransform

modelTransformは、pivotであるhipsからの移動と回転を持つtransformです。
ここで注意しなくてはならないのは、modelTransformは移動量を持つためそのままアバターに適用すると、腕や背格好が実際の人間と同じ割合になってしまいます。
VRMの場合はキャラクターを扱うことが多く、実際の体型と同じとは限らないのでこのtransformを扱うのはあまり適さないかと思います。

localTransform

localTransformは、ある関節ノードの親ノードに対する回転情報のみを持ったtransformです。
基本的にはこのtransformを利用しますが、関節数が異なる箇所に関しては正しく差分を出さないとpivotから遠ざかるほどに誤差が生まれ最終的な姿勢が全く異なるものになってしまうので注意が必要です。

関節の回転差分の出し方

ARSkelton3DのlocalTransformは、純粋な回転量(ここではar_quartanionとします。)+ニュートラル状態に持っていくための回転量の合成であると考えられます。

localTransform = ar_quartanion * neutral_quartanion

では、vrmの関節はneutral_quartanionを引いたar_quartanionだけを適用すれば良いでしょうか?
実はそうではありません。
ar_quartanionで与えられる回転は初期状態に依存するので、T-Pose時点で関節がidentityであるvrmと、neutralが与えられているARSkelton3Dでは合成結果が異なってしまうのです。

そこで、一度各関節がARSkelton3Dのtransformになるようにquartanionを与え、その上でar_quartanionを与えて、最後にARSkelton3Dのquartanion分を戻すことでvrmでも同じ方向に回転を与えることができます。
回転を戻すにはinverseを合成してあげれば良いです。

arOrientaion = neutralOrientation.inverse * transformOrientation
target.simdOrientation = vrmNeutralOrienation * arOrientation * vrmNeutralOrienation.inverse

では、各関節をARSkelton3Dのtransformにするにはどのような計算をすれば良いでしょうか。
単純にlocalTransformを与えると、親やその親の回転を考慮出来ないため愚直に全てのノードの親を辿って計算する必要があります。
ただ、この計算は初回に1度だけしておけば良いのでレンダリングへの影響はほとんどありません。
親のtransformのindexはparentIndicesで取得できるので利用します。
parentIndexが存在しない場合は-1が返ってきます。
VRMはpivotの回転がARSkelton3Dと前後逆なので、この時点でy軸180度の回転をかけてあげます。

lazy var vrmNeutralJointLocalTransforms: [simd_float4x4] = {
        let defaultBody3D = ARSkeletonDefinition.defaultBody3D
        let neutralJointLocalTransforms = defaultBody3D.neutralBodySkeleton3D!.jointLocalTransforms
        var vrmNeutralJointLocalTransforms: [simd_float4x4] = []
        for (var index, localTransform) in neutralJointLocalTransforms.enumerated() {
            var transforms: [simd_float4x4] = [localTransform]
            while let parentIndex = defaultBody3D.parentIndices[safe: index], parentIndex > 0 {
                let parentLocalTransform = neutralJointLocalTransforms[parentIndex]
                transforms.insert(parentLocalTransform, at: 0)
                index = parentIndex
            }
            masterRotation: do {
                let rotate = simd_quaternion(.pi, simd_float3(0, 1, 0))
                transforms[0] = simd_matrix4x4(rotate) * transforms[0]
            }
            vrmNeutralJointLocalTransforms.append(transforms.reduce(simd_float4x4(1), *))
        }
        return vrmNeutralJointLocalTransforms
    }()

完成!

以上の計算を行うことで、ARBodyTranckingでVRMを動作させることが出来るようになりました。

名称未設定のコピー.gif

VRMを使った配信アプリvearでは、これらの処理を行ってモーションキャプチャを実装しています。(v1.2から実装)
興味のある方は是非使ってみてください!
https://apps.apple.com/us/app/vear/id1490697369

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Appendix]Realmが実機で動かなくなった!?

Appendix

本日、Realmを使ってるアプリを実機で起動したら以下のエラーが出ました。
その解決方法をざっと書きます。

dyld: Library not loaded: @rpath/Realm.framework/Realm

XCode上では「dyld`__abort_with_payload:」から始まるファイルが開かれており、「Thread 1: signal SIGABRT」のメッセージが。

原因は分からずでしたが解決方法は、
1.PodFileを更新
2.pod updateを実行
でした。

pod initによって作られたPodFileの

PodFile
  use_frameworks!

PodFile
  # use_frameworks!
  use_modular_headers!

に変更して、そのプロジェクトフォルダに対して
pod updateを実行するだけです。
※pod installした時と同じような感じ。

ターミナル見てたらRealmが4.3.1から4.3.2に変わってたから
それでなんか修正が必要になったんですかねー。

PodFileを更新せずにupdateするだけだと、Realmのversionは上がってましたがエラーメッセージは変わらなかったので、PodFileにuse_moduleなんたらっていうのは入れなくちゃいけないみたいです。

こんなんわかるかい・・・

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

sizeThatFitの使い方, 幅固定 高さ可変なUILabelの作り方

使い方

意外と日本語の記事がなかったので?書きました。

let maxSize = CGSize(width: 100,height:100)
let size = __UIパーツ__.sizeThatFits(maxSize)
__UIパーツ__.frame = CGRect(origin: CGPoint(x:0, y: 0), size: size)

maxSizeで最大の幅と高さを指定しておきます。
その範囲内でいい感じ設定してほしいので、それをsizeThatFitsの引数として代入すると、いい感じに調整してくれたサイズが返ってきます。
この場合は width,heightがそれぞれ100以下の範囲でいい感じに値を返してくれます。
あとはそれを代入すればOK!

具体例(UILabel) 幅を指定値以下で高さを可変

let label = UILabel()
label.numberOfLines = 0
label.text = "lorem ipsum ..........something"
//最大値の設定。 幅固定で高さはいい感じにしたい、と言う場合はこのように高さの最大値を無限大に
let maxSize = CGSize(width: self.view.frame.width - 100, height: CGFloat.greatestFiniteMagnitude)
let size = label.sizeThatFits(maxSize)
//後でcenterを設定するためCGPointのx、yはどんな値でもよき
label.frame = CGRect(origin: CGPoint(x:0, y: 0), size: size)
label.center = self.view.center

これでtextが全てすっぽり収まるけれど、大きすぎないちょうどよいUILabelサイズが計算されます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Google Maps SDK for iOS で経路案内を実装する

はじめに

Google が提供している Directions API と Google Maps SDK for iOS を使用して現在地から目的の場所までのルートを表示してみたいと思います

ドキュメントはこちら

開発環境

Xcode:11.1(11A1027)
Swift 5
iOS:13.1

実装

こちらの Google Maps SDK for iOS で現在地を表示 の続きから記載しています

モデルの作成

まず、経路検索APIのレスポンスをパースするための struct を定義します

Directions API のレスポンス形式

経路検索APIのレスポンス形式は以下のようなものになっています

{
  "status": "OK",
  "geocoded_waypoints" : [
     {
        "geocoder_status" : "OK",
        "place_id" : "ChIJ7cv00DwsDogRAMDACa2m4K8",
        "types" : [ "locality", "political" ]
     },
     {
        "geocoder_status" : "OK",
        "place_id" : "ChIJ69Pk6jdlyIcRDqM1KDY3Fpg",
        "types" : [ "locality", "political" ]
     },
     {
        "geocoder_status" : "OK",
        "place_id" : "ChIJgdL4flSKrYcRnTpP0XQSojM",
        "types" : [ "locality", "political" ]
     },
     {
        "geocoder_status" : "OK",
        "place_id" : "ChIJE9on3F3HwoAR9AhGJW_fL-I",
        "types" : [ "locality", "political" ]
     }
  ],
  "routes": [ {
    "summary": "I-40 W",
    "legs": [ {
      "steps": [ {
        "travel_mode": "DRIVING",
        "start_location": {
          "lat": 41.8507300,
          "lng": -87.6512600
        },
        "end_location": {
          "lat": 41.8525800,
          "lng": -87.6514100
        },
        "polyline": {
          "points": "a~l~Fjk~uOwHJy@P"
        },
        "duration": {
          "value": 19,
          "text": "1 min"
        },
        "html_instructions": "Head \u003cb\u003enorth\u003c/b\u003e on \u003cb\u003eS
        Morgan St\u003c/b\u003e toward \u003cb\u003eW Cermak Rd\u003c/b\u003e",
        "distance": {
          "value": 207,
          "text": "0.1 mi"
        }
      },
      ...
      ... additional steps of this leg
    ...
    ... additional legs of this route
      "duration": {
        "value": 74384,
        "text": "20 hours 40 mins"
      },
      "distance": {
        "value": 2137146,
        "text": "1,328 mi"
      },
      "start_location": {
        "lat": 35.4675602,
        "lng": -97.5164276
      },
      "end_location": {
        "lat": 34.0522342,
        "lng": -118.2436849
      },
      "start_address": "Oklahoma City, OK, USA",
      "end_address": "Los Angeles, CA, USA"
    } ],
    "copyrights": "Map data ©2010 Google, Sanborn",
    "overview_polyline": {
      "points": "a~l~Fjk~uOnzh@vlbBtc~@tsE`vnApw{A`dw@~w\\|tNtqf@l{Yd_Fblh@rxo@b}@xxSfytA
      blk@xxaBeJxlcBb~t@zbh@jc|Bx}C`rv@rw|@rlhA~dVzeo@vrSnc}Axf]fjz@xfFbw~@dz{A~d{A|zOxbrBbdUvpo@`
      cFp~xBc`Hk@nurDznmFfwMbwz@bbl@lq~@loPpxq@bw_@v|{CbtY~jGqeMb{iF|n\\~mbDzeVh_Wr|Efc\\x`Ij{kE}mAb
      ~uF{cNd}xBjp]fulBiwJpgg@|kHntyArpb@bijCk_Kv~eGyqTj_|@`uV`k|DcsNdwxAott@r}q@_gc@nu`CnvHx`k@dse
      @j|p@zpiAp|gEicy@`omFvaErfo@igQxnlApqGze~AsyRzrjAb__@ftyB}pIlo_BflmA~yQftNboWzoAlzp@mz`@|}_
      @fda@jakEitAn{fB_a]lexClshBtmqAdmY_hLxiZd~XtaBndgC"
    },
    "warnings": [ ],
    "waypoint_order": [ 0, 1 ],
    "bounds": {
      "southwest": {
        "lat": 34.0523600,
        "lng": -118.2435600
      },
      "northeast": {
        "lat": 41.8781100,
        "lng": -87.6297900
      }
    }
  } ]
}

モデルの定義

この中で経路案内に必要な情報のみをパースするようにします

struct Direction: Codable {
    let routes: [Route]
}

struct Route: Codable {
    let legs: [Leg]
}

struct Leg: Codable {
    /// 経路のスタート座標
    let startLocation: LocationPoint
    /// 経路の目的地の座標
    let endLocation: LocationPoint
    /// 経路
    let steps: [Step]

    enum CodingKeys: String, CodingKey {
        case startLocation = "start_location"
        case endLocation = "end_location"
        case steps
    }
}

struct Step: Codable {
    let startLocation: LocationPoint
    let endLocation: LocationPoint

    enum CodingKeys: String, CodingKey {
        case startLocation = "start_location"
        case endLocation = "end_location"
    }
}

struct LocationPoint: Codable {
    let lat: Double
    let lng: Double
}

Directions APIから経路を取得し、マップ上に表示

ViewController に以下のようにAPI通信部分を実装していきます

    /// 経路検索APIのエンドポイント. 経路検索APIはSDKとして提供されていないため、エンドポイントはベタ書きになります
    let baseUrl = "https://maps.googleapis.com/maps/api/directions/json"
    /// 仮の目的地の座標(適当に GINZA SIX にしてみています)
    let ginzaSixLocation = "35.669798,139.7639302"

    // ~~~~~~~ 省略 ~~~~~~~

  // 現在地から目的地までのルートを検索する
    private func getDirection(destination: String, start startLocation: String, completion: @escaping (Direction) -> Void) {

        guard var components = URLComponents(string: baseUrl) else { return }

        components.queryItems = [
            URLQueryItem(name: "key", value: GOOGLE_API_KEY),
            URLQueryItem(name: "origin", value: startLocation),
            URLQueryItem(name: "destination", value: destination)
        ]

        guard let url = components.url else { return }
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            if let data = data {
                let decorder = JSONDecoder()
                do {
                    let direction = try decorder.decode(Direction.self, from: data)
                    completion(direction)
                } catch {
                    print(error.localizedDescription)
                }
            } else {
                print(error ?? "Error")
            }
        }
        task.resume()
    }

    private func showRoute(_ direction: Direction) {
        guard let route = direction.routes.first, let leg = route.legs.first else { return }
        let path = GMSMutablePath()
        for step in leg.steps {
            // steps の中には曲がるところの座標が入っているので、
            // 曲がるところの座標を線で結んでいく
            path.add(CLLocationCoordinate2D(latitude: step.startLocation.lat,
                                            longitude: step.startLocation.lng))
            path.add(CLLocationCoordinate2D(latitude: step.endLocation.lat,
                                            longitude: step.endLocation.lng))
        }
        // 曲がるところを結んだ線を Map 上に表示する
        let polyline = GMSPolyline(path: path)
        polyline.strokeWidth = 4.0
        polyline.map = mapView
    }
}

extension ViewController: CLLocationManagerDelegate {

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        switch status {
        case .authorizedAlways, .authorizedWhenInUse:
            // 位置情報の取得が許可されていたら現在地を取得
            manager.requestLocation()
        case .notDetermined:
            manager.requestWhenInUseAuthorization()
        default:
            return
        }
    }

    /// 位置情報取得の認可状態が変化した際とCLLocationManagerのインスタンスが生成された際に呼び出されるメソッド
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }
        let startLocation = "\(location.coordinate.latitude),\(location.coordinate.longitude)"
        getDirection(destination: ginzaSixLocation,
                     start: startLocation,
                     completion: { [weak self] direction in
                        DispatchQueue.main.async {
                            self?.showRoute(direction)
                        }
        })
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print(error.localizedDescription)
    }
}

この状態でビルドを行うと、以下のように表示されているかと思います
Simulator Screen Shot - iPhone 11 Pro Max - 2020-02-09 at 18.31.37.png

縮尺を調節

上記のスクリーンショットの状態だと、広域すぎてルートがわかりにくいです
なので、経路の全域をちょうど表示できるようなところまでマップを拡大する処理を入れていきます

    private func showRoute(_ direction: Direction) {
        guard let route = direction.routes.first, let leg = route.legs.first else { return }
        // ~~~~~~ 省略 ~~~~~~
        polyline.map = mapView

        // 以下、追記
        updateCameraZoom(startLat: leg.startLocation.lat,
                         startLng: leg.startLocation.lng,
                         endLat: leg.endLocation.lat,
                         endLng: leg.endLocation.lng)
    }

    private func updateCameraZoom(startLat: Double, startLng: Double, endLat: Double, endLng: Double) {
        let startCoordinate = CLLocationCoordinate2D(latitude: startLat, longitude: startLng)
        let endCoordinate = CLLocationCoordinate2D(latitude: endLat, longitude: endLng)
        let bounds = GMSCoordinateBounds(coordinate: startCoordinate, coordinate: endCoordinate)
        let cameraUpdate = GMSCameraUpdate.fit(bounds, withPadding: 16.0)
        mapView.moveCamera(cameraUpdate)
    }

上記のように、表示領域を指定してあげると画面内に収まるように自動で縮尺を調節してくれるので、経路が見やすくなります

Simulator Screen Shot - iPhone 11 Pro Max - 2020-02-09 at 18.53.38.png

終わりに

今回は徒歩での経路案内を設定していますが、Directions API のパラメータに設定している "mode": "walking""mode": "drive" に変えてあげると車での経路検索も可能になります

コードの全容は github に載せています
https://github.com/nwatabou/TestCurrentLocation

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[iOS/Swift] アプリ開発の実務的アプローチで学ぶデザインパターン ~Strategy/State~

この記事シリーズは、iOS/Swiftエンジニアである執筆者個人が、
ごく普通のiOSアプリ開発でよくある状況
Swiftのコアライブラリやフレームワークで使われているパターン
着目してデザインパターンを学び直してみた記録です。

関連記事一覧
[iOS/Swift] アプリ開発の実務的アプローチで学ぶデザインパターン

Strategy/Stateパターン概要

  • 条件によって違う処理内容を外部クラスに追い出し、どのクラスを使うかを実行時に選択するパターンです。
  • 具体的には、switch文の「caseが多い/caseごとの処理内容が濃い」場合に、caseごとにクラスを分離させることが多いと思います。
  • 「ユニットテストしやすく」「拡張がしやすい(影響範囲を限定できる)」というメリットがあります。
  • GoFのデザインパターンでは振る舞いに関するパターンに分類されます。
  • StrategyとStateは設計としては同じで、作成側の意図が何にあるかの違いです(と私は解釈しています)。
    • Strategyは性質の違いによる振る舞いの切り替え
    • Stateは状態の変化による振る舞いの切り替え

Strategyパターンの使い所

  • switch文の caseが多い/caseごとの処理内容が濃い 場合

Stateパターンの使い所

私なりの見解では、普通のiOSアプリでは使いどころを見つけるのはなかなか難しいと思います。

一番利用したい場面は『データの「取得開始前」「取得中」「取得成功」「取得エラー」という状態の変化でViewを更新する』ですが、UIKitがそのような設計にマッチしないためです。

サンプルコード

Xcode 11.3 / Swift 5.1 です。
Playgroundにコピペすれば動作します。

Strategyパターンを適用しないサンプル

// 認証パラメータ
struct AuthInfo {
    var id = ""
    var password = ""
    var token = ""

    init(id: String, password: String) {
        self.id = id
        self.password = password
    }
    init(token: String) {
        self.token = token
    }
}

// 認証管理クラス
final class AuthManager {
    enum AuthType {
        case idPassword
        case token
    }

    static func authenticate(by type: AuthType, with authInfo: AuthInfo) {
        switch type {
        case .idPassword:
            if authInfo.id == "id" && authInfo.password == "password" {
                print("ID Password: auth success")
            } else {
                print("ID Password: invalid id or password")
            }
        case .token:
            if authInfo.token == "token" {
                print("Token: auth success")
            } else {
                print("Token: invalid token")
            }
        }
    }
}

AuthManager.authenticate(by: .idPassword, with: AuthInfo(id: "id", password: "password"))
// ID Password: auth success

AuthManager.authenticate(by: .token, with: AuthInfo(token: "token"))
// Token: auth success

Strategyパターンを適用したサンプル

// 認証パラメータ
struct AuthInfo {
    var id = ""
    var password = ""
    var token = ""

    init(id: String, password: String) {
        self.id = id
        self.password = password
    }
    init(token: String) {
        self.token = token
    }
}

// MARK: - Protocol
// 認証プロトコル
protocol AuthStrategy {
    func authenticate(_ authInfo: AuthInfo)
}

// MARK: - Context
// 認証を実行する役割
struct AuthContext {
    let strategy: AuthStrategy

    func execute(with authInfo: AuthInfo) {
        strategy.authenticate(authInfo)
    }
}

// MARK: - Concreate Strategies
// IDパスワード認証
struct IdPasswordAuthStrategy: AuthStrategy {
    func authenticate(_ authInfo: AuthInfo) {
        if authInfo.id == "id" && authInfo.password == "password" {
            print("ID Password: auth success")
        } else {
            print("ID Password: invalid id or password")
        }
    }
}
// トークン認証
struct TokenAuthStrategy: AuthStrategy {
    func authenticate(_ authInfo: AuthInfo) {
        if authInfo.token == "token" {
            print("Token: auth success")
        } else {
            print("Token: invalid token")
        }
    }
}

// MARK: - Usage
var context = AuthContext(strategy: IdPasswordAuthStrategy())
context.execute(with: AuthInfo(id: "id", password: "password"))
// ID Password: auth success

context = AuthContext(strategy: TokenAuthStrategy())
context.execute(with: AuthInfo(token: "token"))
// Token: auth success
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Xcode 11.4アップデートまとめ!

Xcode11.4 Betaが開発者向けに公開された。

image.png

Relese Note

変更

  • macOS Catalina 10.15.2以降を実行しているMacが必要。
  • iOS 13.4, iPadOS 13.4, tvOS 13.4, watchOS 6.2, macOS Catalina 10.15.4に対応したバージョン。
  • IBで制約をつける際に0を入力して「Standard」になるバグを修正

新規

  • Swift 5.2にアップデート
  • SSLの証明書をシミュレーターにたいしてドラッグ&ドロップでインストール可能に
  • iOSシミュレータがプッシュ通知のシミュレーション可能に
  • callAsFunctionが追加
  • IB(インターフェースビルダー)にシステムグレーが追加

adder.callAsFunction(10)をadder(10)と省略してかけるようになった。

struct Adder {
    var base: Int

    func callAsFunction(_ x: Int) -> Int {
      return x + base
    }
}

var adder = Adder(base: 3)
adder(10) // returns 13, same as adder.callAsFunction(10)
  • simctlキーチェーンサブコマンドをサポート
xcrun simctl keychain <device> add-root-cert my-selfsigned.cer
  • simctlプライバシー権限の変更をサポート
# アクセスできるようにする
xcrun simctl privacy <device> grant photos com.example.app
# 権限をデフォルトにリセットする
xcrun simctl privacy <device> reset all com.example.app

参考記事

最後に

  • ベータ版ではなく正式版が出た際にもう一度更新します。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UTIを調べるCLIツールをSwiftで作ったメモ書き

UTIって何?

Uniform Type Identifier(UTI)はデータ(エンティティ)のタイプ(種類、型)を一意に識別する文字列である。 Wikipedia

UTI調べてpublic.jpegであれば、a.jpegファイルでb.jpgであろうとJPEGファイルということがわかる。

UTI調べて何するの?

作ろうとしていたツールで、NSOpenPanelから拡張子が特定ものだけしか選択できないようにしたかった結果、UTIを指定する必要があった。

Apple独自の拡張子とかは特にUTIがわからないので、困りがち。
movie.movはUTIがcom.apple.quicktime-movieだったりするし…。

なんでCLI作ったん?

プログラムでUTIを調べることは簡単にできたものの、UTI必要になったタイミングでそのプログム毎回buildするの?って思ったら面倒じゃん。何回も調べるかと言われたらわからないけど、terminalでファイルを引数に渡してすぐ調べられた方が楽じゃん。と思って、CLI作った。

CLIの作り方(Swift製)

この2つでほぼ問題なく出来る。

install方法は、make installでやりたかったので、以前お世話になったSimulatorsを参考に必要部分だけ変更して作成。

上記内容を参考に作っていたけど、release buildしたものを実行しようとすると次のerrorが出て上手くいかなかった(sutiは今回作ったCLIのbin名)

dyld: Library not loaded: @rpath/libswiftCore.dylib
  Referenced from: /usr/local/bin/suti
  Reason: image not found

調べた結果、buildの仕方が変わって

❌before
swift build -c release -Xswiftc -static-stdlib
⭕️after
swift build -c release

となる。

Swift ForumのHow to `Always Embed Swift Standard Libraries`?に書いてあった。

SUTI

そして出来上がったのが、SUTI(Search Uniform Type Identifierの略)

example
$ suti ~/Desktop/sample.jpg
UTI is public.jpeg

注意点

  • 10行レベルのコードなのでテストコード書いてない
  • UTI調べていて、1点問題があって、拡張子の偽装は見抜けない。

元がa.jpegのファイルの拡張子を変えてa.pngとしてsutiに渡したところ

UTI is public.png

と表示されてしまった。あくまで、拡張子からUTIを判別することしか出来ない。

ただ、これでApple独自の拡張子からUTIを調べられるようになった。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UITextViewの特定の文字列を下線付きリンクにする

開発環境: Xcode11.3/Swift5.1.3

概要

今回は文字色の指定、フォント指定、リンク化や下線などの複数の要素を簡単に追加していくため、
NSAttributedStringではなくNSMutableAttributedStringを利用した実装をしていきます。

GitHub: UITextViewWithLink

ひとまず完成GIF

通信が遅いのはご愛敬ということで...
スカイスパがとても良さそう

実装

下準備

ViewControllerのメンバ変数に使用する
- UITextView
- 表示したい文
- 下線付きリンクにしたい特定の文字列
を定義しておきます。(URLも定義しておいた方がいいのでは)

MainViewController.swift
private let textViewWithLink = UITextView()

private let mainText = "そろそろ横浜スカイスパと戸越銀座温泉に行きたい"
private let skySpaText = "横浜スカイスパ"
private let togoshiGinzaBathText = "戸越銀座温泉"

UITextViewの設定

今回の目的はタップした際に、リンクからWebページに遷移することなので、
isEditableisScrollEnabledは一応無効にしておきます。
(isEditable = trueでもリンクから遷移できたので、DelegateやRx等を利用すれば
打ち込まれた文字列を評価して、特定の文字だけリンク化させることもできますね)

MainViewController.swift
textViewWithLink.text = mainText
textViewWithLink.isScrollEnabled = false
textViewWithLink.isEditable = false

NSMutableAttributedString

表題を達成するためのクラスです。説明は公式ドキュメントを参照してください。

Attributeの追加

こんな関数を用意しています。
下線付きリンクを設定したい文字列を含むUITextViewを引数に取り、
関数内部で特定の文字列に加工を施していきます。

MainViewController.swift
private func addLinkAttributes(target textView: UITextView) {
    // NSMutableAttributedStringのinitializerに渡す文字列を、UITextViewから取得
    guard let targetText = textView.text else { return }
    let attributedString = NSMutableAttributedString(string: targetText)
    // 追加したいAttributeを追加していく
    attributedString.addAttribute(.foregroundColor, // 文字の色
                                  value: UIColor.label,
                                  range: NSString(string: targetText).range(of: targetText))
    attributedString.addAttribute(.font, // フォント
                                  value: UIFont.boldSystemFont(ofSize: 28),
                                  range: NSString(string: targetText).range(of: targetText))
    attributedString.addAttribute(.link, // リンク化
                                  value: "https://www.skyspa.co.jp/",
                                  range: NSString(string: targetText).range(of: skySpaText))
    attributedString.addAttribute(.underlineStyle, // 下線
                                  value: NSUnderlineStyle.single.rawValue,
                                  range: NSString(string: targetText).range(of: skySpaText))
    attributedString.addAttribute(.link, // リンク化
                                  value: "http://togoshiginzaonsen.com/",
                                  range: NSString(string: targetText).range(of: togoshiGinzaBathText))
    attributedString.addAttribute(.underlineStyle, // 下線
                                  value: NSUnderlineStyle.single.rawValue,
                                  range: NSString(string: targetText).range(of: togoshiGinzaBathText))
    // 対象となるUITextViewのattributedTextプロパティにAttributeを追加したattributedStringを格納する
    textView.attributedText = attributedString
}

...うぅん...なんだか繰り返し似た処理書いてる...面倒臭さい...

大丈夫です、こんな悩みを解決する術もちゃんと用意されています

addAttribute(_:value:range:)

先ほど利用していたメソッドです。(公式ドキュメント)
Attributeをひとつずつ追加していく場合に利用します。
が、先ほどのコードの様に
同じ特定の文字列に対して複数のAttributeを追加する際には、繰り返し記述が必要で若干不便です。

addAttributes(_:range:)

そんな上記の悩みを解決するメソッドです。(公式ドキュメント)

MainViewController.swift
private func addLinkAttributes(target textView: UITextView) {
    guard let targetText = textView.text else { return }
    let attributedString = NSMutableAttributedString(string: targetText)
    attributedString.addAttributes([.foregroundColor: UIColor.label,
                                    .font: UIFont.boldSystemFont(ofSize: 28)],
                                   range: NSString(string: targetText).range(of: targetText))
    attributedString.addAttributes([.link: "https://www.skyspa.co.jp/",
                                    .underlineStyle: NSUnderlineStyle.single.rawValue],
                                   range: NSString(string: targetText).range(of: skySpaText))
    attributedString.addAttributes([.link: "http://togoshiginzaonsen.com/",
                                    .underlineStyle: NSUnderlineStyle.single.rawValue],
                                   range: NSString(string: targetText).range(of: togoshiGinzaBathText))
    textView.attributedText = attributedString
}

第一引数に追加したいAttributeをまとめて渡します。
引数の型は[NSAttributedString.Key : Any]で、keyとvalueを指定するだけです。
だいぶスッキリしましたね。

気を付けること

NSMutableAttributedStringを利用して文字列を加工する場合、
事前にUITextViewに設定したフォントサイズや色などは上書きされるので、
その分のAttributeを追加する必要があります(今回で言うとこの.foregroundColor.font)。

後記

UITextViewのリンクでの画面遷移が若干遅い気がしたので、タイムラグを減らそうと
UILabelとUITapGestureRecognizerを用いての実装も試しました。
けれども、NSLayoutManagerNSTextContainerなど
登場するクラスも増え、座標取得する必要も出てきたので
大人しく標準仕様に従う方が良さそうです。
(気になる方はこちらの記事をご覧ください。)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AutoLayOutを使ったUIScrollViewの作り方

AutoLayOutを使ったUIScrollViewの作り方

https://github.com/objcToScript/scrollSample

ViewControllerのサイズをFreeDomにする 高さを設定する ここでは2000px
スクリーンショット 2020-02-08 19.58.22.png
UIScrollViewを配置する。高さを設定する。ここでは2000px
スクリーンショット 2020-02-09 10.54.52.png
UIScrollViewの中にUIViewを入れる。必ず名前を変更してください。 ここではContentViewという名前にします。高さを設定します。ここでは2000px
スクリーンショット 2020-02-09 10.54.59.png
左のパネルと右のパネル ここを中心に使っていきます。
├□┤ボタンは混乱するのもとなのであまり使わないほうがいい
├△┤ボタンはupdate clearなどに使います
スクリーンショット 2020-02-08 20.10.15.png
ContentViewからUIScrollViewに制約をつけます。

紐付け方

 ContentViewを選択する。
 Cntrlキーを押しながらUIScrollViewに線を紐づけます。
 上下左右にUIScrollViewに制約を紐づけます
 WidthもUIScrollViewに制約を追加します。
スクリーンショット 2020-02-08 20.10.02.png
紐づけると画像のように白い点が付きます。
スクリーンショット 2020-02-08 20.10.23.png
同じようにUIScrollViewから上の階層のUIViewにも制約を紐づけます。
上下左右に一番上のUIViewに制約を紐づけます
画像のような状態にします。余分な制約数値が入ってしまった場合は、右のパネルで修正します。余分な数字に0を入力します。
スクリーンショット 2020-02-09 11.07.20.png
Reverseを使うと左のパネルの数値が見やすくなります。
スクリーンショット 2020-02-08 20.11.04.png
ContentViewの中に必ず何かviewを置きます。つっかい棒的なものがないとうまくスクロールできません
スクリーンショット 2020-02-08 20.18.51.png

うまくやるコツ

左と右のAutoLayOutのパネルを主に使う
制約付けも左のパネルでCtrlキーを押してドラッグして制約づけする
制約の値の修正は右のパネルでやる
├□┤ボタンはあまり使わないほうがよい
├△┤ボタンはupdate clearなどに使う

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

NWPusherで手軽にiPhone実機へプッシュ通知を送り、テストをする

iPhoneアプリのデバッグ過程でプッシュ通知をテスト端末に送る必要があったのですが、
NWPusherのおかげで手軽に何度もプッシュ通知を送ることができ、とても捗りました!

NWPusherをインストールする

GitHubのページは以下です。
GitHub - noodlewerk/NWPusher: OS X and iOS application and framework to play with the Apple Push Notification service (APNs)

Readmeに記載されていますが、Homebrew caskでのインストールがオススメです。

brew cask install pusher

Homeberwを使わない場合は最新のバイナリーがダウンロードできます。
Latest Release · noodlewerk/NWPusher · GitHub

インストールできたら、NWPusher(以下Pusher)を起動します。

p12ファイルのインポート

p12ファイルが手元にない場合は、作成するか、権限のある人に依頼しましょう!
【iOS】複雑な証明書周りをあっさり整理してみた – ゆるtech。

p12ファイルを入手できたら、
PusherアプリのImport PKCS #12 file (.p12)...からインポートします。

デバイストークンの入手

プッシュ通知の送り先を識別するための端末個別のトークンが必要なので、下記のオーバーライドメソッドで取得します。
8桁×8の英数字文字列がコンソールに出力されるのでコピーしましょう。

Swift

AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // ① プッシュ通知利用許可のリクエスト
    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
        guard granted else { return }

        DispatchQueue.main.async {
            // ② プッシュ通知利用の登録
            UIApplication.shared.registerForRemoteNotifications()
        }
    }
    return true
}

// ③ プッシュ通知の利用登録が成功した場合(引数でデバイストークンが取れる)
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    print("Device token: \(deviceToken)")
}

// ④ (省略可)プッシュ通知の利用登録が失敗した場合はこちらが呼ばれる
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
    print("Failed to register to APNs: \(error)")
}

Objective-C

AppDelegate.m
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    // ① プッシュ通知利用許可のリクエスト
    [[UNUserNotificationCenter currentNotificationCenter] requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionBadge | UNAuthorizationOptionSound) completionHandler:^(BOOL granted, NSError * _Nullable error) {
        if (granted) {
            dispatch_async(dispatch_get_main_queue(), ^{
                // ② プッシュ通知利用の登録
                [UIApplication.sharedApplication registerForRemoteNotifications];
            });
        }
    }];
    return YES;
}

// ③ プッシュ通知の利用登録が成功した場合(引数でデバイストークンが取れる)
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
    NSLog(@"Device token: %@", deviceToken);
}

// ④ (省略可)プッシュ通知の利用登録が失敗した場合はこちらが呼ばれる
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
    NSLog(@"Failed to register to APNs: %@", error);
}

デバイストークンの入力

コピーしたデバイストークン(8桁×8の英数字文字列)をPusherアプリに入力します。
8桁ごとにスペースで区切られたままでも大丈夫です。

  • Expiry(有効期限)
  • Priority(優先度)

を設定することもできますが、デフォルトのNoneでも問題ありません。

書かなかったこと

以下のことは試していないです。
GitHubのReadmeに詳しくHow-toが書いてありました。

  • CocoaPodsまたはCarthageを用いて、iPhoneアプリにフレームワークを入れればiPhoneからプッシュ通知を送ることができる。
  • iOS, macOS で、プログラムを書いて通知を送信するためのフレームワークとしても使用できる。

参考リンク

iOS13におけるプッシュ通知に必要なデバイストークンの取得方法 - Takahiro Octopress Blog

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む