20191219のiOSに関する記事は12件です。

Xcode11の新規プロジェクト作成につまずいた

はじめに

Xcode11 で新規にプロジェクトを作成し、iOS13 未満もサポートしようとすると大量のエラーが出てしまいます:dizzy_face:

error

この辺りめんどくさくて最近作ったやつ(下記とか)は大抵 iOS13 以上サポートにしてましたが iOS13 未満もサポートするため調べてみました。

対応

SwiftUI を使わない

とりあえず新規作成時に User Interface で SwiftUI ではなく Storyboard を選択する。

ui

頑張ってバージョンで分岐させてもいいがおとなしく今のところは SwiftUI をあきらめる:poop:

SceneDelegate 対応

出てるエラーを見てもらうとわかる通り SceneDelegate に関連するものばかりです。

SceneDelegate とはなんぞや?というのは下記参考

対応としては下記2パターンです。

  1. SceneDelegate を削除する
  2. エラーに1つずつ対応する

SceneDelegate を削除する

SceneDelegate はどうやら必須ではないらしいので削除しても大丈夫です。

※機能が制限されるのでそことの兼ね合い

  1. Info.plist の Application Scene Manifest キーを削除する
    Application Scene Manifest キーがある場合に必須となるのでこれを削除する。
    plist
  2. SceneDelegate.swift を削除する

さあビルドっとやるとまだエラーが出る:scream_cat:

error_2

ごっそり削除

AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    return true
  }
}

ビルド!!!

画面が真っ黒でこんなエラーが

[Application] The app delegate must implement the window property if it wants to use a main storyboard file.

とりあえず window を追加!!

AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

  var window: UIWindow? // これ追加

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    return true
  }
}

ビルド!!!!

iOS12 でも動いた:tada:

エラーに1つずつ対応する

エラーをみていくと全部 @available(iOS 13.0, *) をつけろ!っていうやつです。
下記のように @available(iOS 13.0, *) をつけて削除の時と同様に window を追加します。

SceneDelegate.swift
@available(iOS 13.0, *) // ここ追加
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
        guard let _ = (scene as? UIWindowScene) else { return }
    }
  :
  :
  :
AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

  var window: UIWindow? // これ追加

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    return true
  }

  // MARK: UISceneSession Lifecycle
  @available(iOS 13.0, *) // ここ追加
  func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    // Called when a new scene session is being created.
    // Use this method to select a configuration to create the new scene with.
    return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
  }

  @available(iOS 13.0, *) // ここ追加
  func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
    // Called when the user discards a scene session.
    // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
    // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
  }
}

2019/12/21追記。コメントでもっとクールな書き方を教えていただきました:heart_eyes:

AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

  var window: UIWindow? // これ追加

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    return true
  }
}

// MARK: UISceneSession Lifecycle
@available(iOS 13.0, *) // ここ追加
extension AppDelegate {
  func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    // Called when a new scene session is being created.
    // Use this method to select a configuration to create the new scene with.
    return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
  }

  func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
    // Called when the user discards a scene session.
    // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
    // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
  }
}

ビルド!!!!!

iOS12 でも動いた:tada::tada:

SceneDelegate があると AppDelegate と両方に処理を書かないといけなくなるので 許してくれるなら(お客さんが)削除でいいんじゃないかなと思います:unamused:

2019/12/21追記。
こちらもコメントいただきました。iOS14 などで今後 AppDelegate の処理が SceneDelegate に移行する可能性もあるのでしっかりバージョンを切り分けて対応した方が無難とのこと:frowning2:(iPhone なら Scene とか関係ないし削除しても...でも結局今後どう変わるかわからないし...)

なのでできるならちゃんと対応しましょう!!!

なんどもこの作業をやるのがめんどくさい場合はテンプレート機能とかあるらしいです(...知らなかった:see_no_evil:

さいごに

なんとかまにあった...:mask:

(半角スペース2個で改行できるの知らなかった:speak_no_evil:

参考

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

クリスマス前にARでジョークアプリをつくってみた話

この記事はiOS #2 Advent Calendar 2019の19日目の記事になります。
あんまり今年は書くモチベなかったのですが、ちょうどわいたところに空きがあって飛び入り参加しました。

とうようです。

普段は個人でiOSアプリつくったり、大学院でUIの研究室にいたり、中高生にITを教えたりしています。
ちなみに大学生・大学院生向けにちょうどこのLife is Tech !という中高生にITを教える団体のメンター募集がはじまっているので良かったら応募してみてください!笑
楽しい上にいろんなスキルが身につくのでぜひ!

>ーー切り取り線ーー<

さて、本題。

はじまり

はじまりはこの秋、2019年9月にさかのぼります。

増税前のiPhone 11シリーズの発表。僕は長年iPhone 7 Plusを利用していたのですが、そろそろ欲しいと。
Face ID使ってみたいと。

そんなわけでSIMフリーのiPhone 11 Pro Maxを購入しました!(パチパチパチ)

image.png

今までの機体も開発用に残しておこうという判断をした結果、手元にはこのようにiPhone 7 PlusとiPhone 11 Pro Maxの二台が残りました。

image.png

そう二台が...。

あっ!!!!!!!!!!

そこで気付いてしまったのです。この二台がある意味を...。
思いついてしまったものは仕方がないと、早速制作をはじめました。

手始め

まず手始めにARに表示するオブジェクトを制作しようということになりました。

といってもこんなにくだらないアプリにわざわざMayaやBlendarを使うのもなぁということで最初に検討したのがこれ

Reality Composer!!!

これはiOS13とXcode11向けにWWDC2019で発表されたApple公式の3Dモデル製作ツールです。そう、Apple公式の。大事なことなので2回言いました。

触ってみるとすごく簡単にリッチな3Dモデルがつくれてめっっっっちゃ楽しいです。詳しいことはこちらの記事などをご覧ください。

ですがこれは自分のやりたかったことに対して自由度が少し低い(ような気がする)という欠点がありました。

しかも今回つくらなければいけないのはめちゃくちゃ単純なモデルです。
そこで次に採用を決めたのがXcodeについているSceneKitのモデル製作画面です。

こちらの記事が参考になります。

というわけでとても簡単な平面のモデルをつくりました。えいやっ!

image.png

実際の模様はコードで反映していくので現時点ではめちゃくちゃ簡単です。

アプリの機能を変える

続いてこのアプリはiPhone 7 PlusとiPhone 11 Pro Maxの役割を完全に分けようという方針を考えたので、その実装をはじめました。

そのためにはデバイスの種類が取れるようにしなければいけません。なのでこちらを参考にこのような関数を追加しました。

func getDeviceInfo() -> String {
    var size: Int = 0
    sysctlbyname("hw.machine", nil, &size, nil, 0)
    var machine = [CChar](repeating: 0, count: Int(size))
    sysctlbyname("hw.machine", &machine, &size, nil, 0)
    let code: String = String(cString: machine)

    let deviceCodeDic: [String: String] = [
        /* Simulator */
        "i386"      : "Simulator",
        "x86_64"    : "Simulator",
        /* iPod */
        "iPod1,1"   : "iPod Touch 1st",            // iPod Touch 1st Generation
        "iPod2,1"   : "iPod Touch 2nd",            // iPod Touch 2nd Generation
        "iPod3,1"   : "iPod Touch 3rd",            // iPod Touch 3rd Generation
        "iPod4,1"   : "iPod Touch 4th",            // iPod Touch 4th Generation
        "iPod5,1"   : "iPod Touch 5th",            // iPod Touch 5th Generation
        "iPod7,1"   : "iPod Touch 6th",            // iPod Touch 6th Generation
        /* iPhone */
        "iPhone1,1"   : "iPhone 2G",                 // iPhone 2G
        "iPhone1,2"   : "iPhone 3G",                 // iPhone 3G
        "iPhone2,1"   : "iPhone 3GS",                // iPhone 3GS
        "iPhone3,1"   : "iPhone 4",                  // iPhone 4 GSM
        "iPhone3,2"   : "iPhone 4",                  // iPhone 4 GSM 2012
        "iPhone3,3"   : "iPhone 4",                  // iPhone 4 CDMA For Verizon,Sprint
        "iPhone4,1"   : "iPhone 4S",                 // iPhone 4S
        "iPhone5,1"   : "iPhone 5",                  // iPhone 5 GSM
        "iPhone5,2"   : "iPhone 5",                  // iPhone 5 Global
        "iPhone5,3"   : "iPhone 5c",                 // iPhone 5c GSM
        "iPhone5,4"   : "iPhone 5c",                 // iPhone 5c Global
        "iPhone6,1"   : "iPhone 5s",                 // iPhone 5s GSM
        "iPhone6,2"   : "iPhone 5s",                 // iPhone 5s Global
        "iPhone7,1"   : "iPhone 6 Plus",             // iPhone 6 Plus
        "iPhone7,2"   : "iPhone 6",                  // iPhone 6
        "iPhone8,1"   : "iPhone 6S",                 // iPhone 6S
        "iPhone8,2"   : "iPhone 6S Plus",            // iPhone 6S Plus
        "iPhone8,4"   : "iPhone SE" ,                // iPhone SE
        "iPhone9,1"   : "iPhone 7",                  // iPhone 7 A1660,A1779,A1780
        "iPhone9,3"   : "iPhone 7",                  // iPhone 7 A1778
        "iPhone9,2"   : "iPhone 7 Plus",             // iPhone 7 Plus A1661,A1785,A1786
        "iPhone9,4"   : "iPhone 7 Plus",             // iPhone 7 Plus A1784
        "iPhone10,1"  : "iPhone 8",                  // iPhone 8 A1863,A1906,A1907
        "iPhone10,4"  : "iPhone 8",                  // iPhone 8 A1905
        "iPhone10,2"  : "iPhone 8 Plus",             // iPhone 8 Plus A1864,A1898,A1899
        "iPhone10,5"  : "iPhone 8 Plus",             // iPhone 8 Plus A1897
        "iPhone10,3"  : "iPhone X",                  // iPhone X A1865,A1902
        "iPhone10,6"  : "iPhone X",                  // iPhone X A1901
        "iPhone11,8"  : "iPhone XR",                 // iPhone XR A1984,A2105,A2106,A2108
        "iPhone11,2"  : "iPhone XS",                 // iPhone XS A2097,A2098
        "iPhone11,4"  : "iPhone XS Max",             // iPhone XS Max A1921,A2103
        "iPhone11,6"  : "iPhone XS Max",             // iPhone XS Max A2104
        "iPhone12,5"  : "iPhone 11 Pro Max",         // iPhone 11 Pro Max

        /* iPad */
        "iPad1,1"   : "iPad 1 ",                     // iPad 1
        "iPad2,1"   : "iPad 2 WiFi",                 // iPad 2
        "iPad2,2"   : "iPad 2 Cell",                 // iPad 2 GSM
        "iPad2,3"   : "iPad 2 Cell",                 // iPad 2 CDMA (Cellular)
        "iPad2,4"   : "iPad 2 WiFi",                 // iPad 2 Mid2012
        "iPad2,5"   : "iPad Mini WiFi",              // iPad Mini WiFi
        "iPad2,6"   : "iPad Mini Cell",              // iPad Mini GSM (Cellular)
        "iPad2,7"   : "iPad Mini Cell",              // iPad Mini Global (Cellular)
        "iPad3,1"   : "iPad 3 WiFi",                 // iPad 3 WiFi
        "iPad3,2"   : "iPad 3 Cell",                 // iPad 3 CDMA (Cellular)
        "iPad3,3"   : "iPad 3 Cell",                 // iPad 3 GSM (Cellular)
        "iPad3,4"   : "iPad 4 WiFi",                 // iPad 4 WiFi
        "iPad3,5"   : "iPad 4 Cell",                 // iPad 4 GSM (Cellular)
        "iPad3,6"   : "iPad 4 Cell",                 // iPad 4 Global (Cellular)
        "iPad4,1"   : "iPad Air WiFi",               // iPad Air WiFi
        "iPad4,2"   : "iPad Air Cell",               // iPad Air Cellular
        "iPad4,3"   : "iPad Air China",              // iPad Air ChinaModel
        "iPad4,4"   : "iPad Mini 2 WiFi",            // iPad mini 2 WiFi
        "iPad4,5"   : "iPad Mini 2 Cell",            // iPad mini 2 Cellular
        "iPad4,6"   : "iPad Mini 2 China",           // iPad mini 2 ChinaModel
        "iPad4,7"   : "iPad Mini 3 WiFi",            // iPad mini 3 WiFi
        "iPad4,8"   : "iPad Mini 3 Cell",            // iPad mini 3 Cellular
        "iPad4,9"   : "iPad Mini 3 China",           // iPad mini 3 ChinaModel
        "iPad5,1"   : "iPad Mini 4 WiFi",            // iPad Mini 4 WiFi
        "iPad5,2"   : "iPad Mini 4 Cell",            // iPad Mini 4 Cellular
        "iPad5,3"   : "iPad Air 2 WiFi",             // iPad Air 2 WiFi
        "iPad5,4"   : "iPad Air 2 Cell",             // iPad Air 2 Cellular
        "iPad6,3"   : "iPad Pro 9.7inch WiFi",       // iPad Pro 9.7inch WiFi
        "iPad6,4"   : "iPad Pro 9.7inch Cell",       // iPad Pro 9.7inch Cellular
        "iPad6,7"   : "iPad Pro 12.9inch WiFi",      // iPad Pro 12.9inch WiFi
        "iPad6,8"   : "iPad Pro 12.9inch Cell",      // iPad Pro 12.9inch Cellular
        "iPad6,11"  : "iPad 5th",                    // iPad 5th Generation WiFi
        "iPad6,12"  : "iPad 5th",                    // iPad 5th Generation Cellular
        "iPad7,1"   : "iPad Pro 12.9inch 2nd",       // iPad Pro 12.9inch 2nd Generation WiFi
        "iPad7,2"   : "iPad Pro 12.9inch 2nd",       // iPad Pro 12.9inch 2nd Generation Cellular
        "iPad7,3"   : "iPad Pro 10.5inch",           // iPad Pro 10.5inch A1701 WiFi
        "iPad7,4"   : "iPad Pro 10.5inch",           // iPad Pro 10.5inch A1709 Cellular
        "iPad7,5"   : "iPad 6th",                    // iPad 6th Generation WiFi
        "iPad7,6"   : "iPad 6th",                    // iPad 6th Generation Cellular
        "iPad8,1"   : "iPad Pro 11inch WiFi",        // iPad Pro 11inch WiFi
        "iPad8,2"   : "iPad Pro 11inch WiFi",        // iPad Pro 11inch WiFi
        "iPad8,3"   : "iPad Pro 11inch Cell",        // iPad Pro 11inch Cellular
        "iPad8,4"   : "iPad Pro 11inch Cell",        // iPad Pro 11inch Cellular
        "iPad8,5"   : "iPad Pro 12.9inch WiFi",      // iPad Pro 12.9inch WiFi
        "iPad8,6"   : "iPad Pro 12.9inch WiFi",      // iPad Pro 12.9inch WiFi
        "iPad8,7"   : "iPad Pro 12.9inch Cell",      // iPad Pro 12.9inch Cellular
        "iPad8,8"   : "iPad Pro 12.9inch Cell",      // iPad Pro 12.9inch Cellular
        "iPad11,1"  : "iPad Mini 5th WiFi",          // iPad mini 5th WiFi
        "iPad11,2"  : "iPad Mini 5th Cell",          // iPad mini 5th Cellular
        "iPad11,3"  : "iPad Air 3rd WiFi",           // iPad Air 3rd generation WiFi
        "iPad11,4"  : "iPad Air 3rd Cell"            // iPad Air 3rd generation Cellular
    ]

    if let deviceName = deviceCodeDic[code] {
        return deviceName
    } else {
        if code.range(of: "iPod") != nil {
            return "iPod Touch"
        }else if code.range(of: "iPad") != nil {
            return "iPad"
        }else if code.range(of: "iPhone") != nil {
            print(code)
            return "iPhone"
        }else{
            return "unknownDevice"
        }
    }
}

ちなみにdeviceCodeがなんなのかは参考記事以外全然情報を見つけられなかったので自分の機体のcodeを一旦printして付け足しました(本当は全デバイスに対応したい...)

これで準備が整ったので次に特定のデバイスの時だけ処理を切り分けるということを行います。これはデバイスの正式名称が手に入っているのでcontainsを使えばいいです。

たとえば今回はiPhone 7系の時だけ別のストーリーボードから出したかったのでAppDelegateにこのように書きました(もちろんSceneDelegateがあるプロジェクトの場合はそちらに書くべき処理です。)

AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    let deviceName = getDeviceInfo()
    let imageStoryboard = UIStoryboard(name: "SevenImage", bundle: nil)
    if deviceName.contains("iPhone 7") {
        window?.rootViewController = imageStoryboard.instantiateInitialViewController()
    }
    return true
}

以降の処理の書き分けも同様のif文を用いてできます。

画像に反応させる

(だんだんタイトルが真面目になってきましたが)次に考えたのは画像に反応してAR上で何かをするというものです。
代表的なところでいうとAR名刺などが有名ですね。あとは海外の中高生デベロッパーがARKit出たてのころ、ゲームのパッケージをアンカーにゲームの紹介映像を流すサンプルアプリを制作してバズったりなんかもしてました。

僕もこれらを参考にやっていきます。
今回アンカーにしたのがこちらの画像

seven.png

最終目標はこの画像をアンカーにしてそこに3DオブジェクトをARで重ねるというものになります。

これはiPhone 7系でアプリを実行すると全画面表示されるようになっています。

つまり!

iPhone 7系ともう一台のiPhoneがあってはじめて成立するアプリ、それがこのアプリの最終形です!!!!!!!

気を取り直して、

この具体的なやり方はAR名刺の記事や最後に載せる完成形コードを見てもらえればいいのですが、ひとつ注意する点としては画像マーカーは必ずAssets.xcassetsの中にAR Resourcesというグループをつくって登録しなければならないという点です。Assets.xcassetsにそのまま入れた画像をマーカーにしようとしてもうまくいかなかったので注意しましょう。

...さてなんやかんやアニメーションなども適当に加えて、AR系の処理はiPhone 11系でしか実行されないように変えて、完成です。

実際にやってみた

さて実際にやってみた動画がこちら(YouTube)になります。

iPhone 7で画像マーカーが出て、それをiPhone 11で見てみると

そう!iPhone 7とiPhone 11がウーン!(CV:ピコ太郎)ってなってセブンイレブンロゴが出てきました!!!!!!!セブンとイレブンがあわさってセブンイレブン!!!!

image.png

......

..........

あ、ごめんなさい。死ぬほど引っ張ってきて結論これです...完全にオヤジギャグです。。。はい。

つまったところ

今回地味につまったところとしてARSCNViewを動かしたときに以下のWarningが出て画面が真っ暗のままになるという現象がありました。

2019-12-19 16:55:33.111128+0900 SevenElevenAR[22084:3756213] [Session] Session (0x113e3e9d0): did fail with error: Error Domain=com.apple.arkit.error Code=102 "Required sensor failed." UserInfo={NSLocalizedFailureReason=A sensor failed to deliver the required input., NSUnderlyingError=0x280329f20 {Error Domain=AVFoundationErrorDomain Code=-11800 "The operation could not be completed" UserInfo={NSLocalizedFailureReason=An unknown error occurred (-12780), NSLocalizedDescription=The operation could not be completed, NSUnderlyingError=0x280329740 {Error Domain=NSOSStatusErrorDomain Code=-12780 "(null)"}}}, NSLocalizedRecoverySuggestion=Make sure that the application has the required privacy settings., NSLocalizedDescription=Required sensor failed.}

これはいくつかフォーラムやStackOverflowにも報告されていて、iPhone側の設定でコンパスのキャリブレーションをONにするというものもあるそうなのですが、とりあえずは以下のコードを書くことによって直りました。

func restartSessionWithoutDelete() {
    arScnView.session.pause()
    arScnView.session.run(imageConfiguration, options: [.resetTracking, .removeExistingAnchors])
}

func session(_ session: ARSession, didFailWithError error: Error) {
    if let arError = error as? ARError {
        switch arError.errorCode {
        case 102:
            imageConfiguration.worldAlignment = .gravity
            restartSessionWithoutDelete()
        default:
            restartSessionWithoutDelete()
        }
    }
}

締め

というわけで今回は、完全に思いつきからARKitでちょっとしたダジャレアプリをつくってみました。

ハマりどころはたしかにあったものの、どんどんと手軽にこのぐらいのアプリなら作れるようになってきていていい世の中になっていってるなぁという気持ちです。
こちら完成品のコードを置いておくのでぜひ試してみてください笑

今日の記事以外にもARKitまわりではいくつか記事を書いているので興味があれば読んでみてください!

最近どんどんとAppStoreの審査基準は厳しくなっていますがこんなふうに遊びのアプリだったらどんどん自由な発想で作ってみるのが面白いんじゃないかなと思います。ぜひ面白いものをつくって技術力を上げていきましょう!

追伸

友達の面白い記事の雰囲気を参考に書いてみたのですが、Qiitaよりももっと自由に装飾できる媒体でやるべきでしたね。。。笑

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

Swiftでクリスマスツリーを描画してみた

久々の投稿でSwift縛りでどんな記事にしよか…

Swiftでなにかクリスマスっぽい事をしたいと思ったけど
案が何も思いつかない。。。。

ひらめいた!!

昔、フラクタル図形の木をObjective-Cで作った事があるから
それをSwiftで作ったらええんや??

そうと決まれば真似インスパイアできるコードを探すんや!!

全然ええ感じのやつが見つからへん…

ちょっと微妙な木の形のやつならインスパイアできそうやし
こうなったらちょっと妥協するか。。

どや!
Simulator Screen Shot - iPhone 8 - 2019-12-16 at 21.27.58.png
(これだとただのフラクタル図やな、、、せや!クリスマスっぽく装飾しよ!)

でやぁぁ!!
Simulator Screen Shot - iPhone 8 - 2019-12-16 at 21.42.42.png
(なんかまだまだクリスマスっぽくないな、、、
黄色一色の飾り付けがあかんねやな、カラフルにしたろ!)

とうぅぅぅ!!
Simulator Screen Shot - iPhone 8 - 2019-12-16 at 21.44.56.png
(だいぶクリスマスっぽいけどまだなんか足らんなーー、、
クリスマスって夜のイメージやから背景は黒やな!)

はぁッッッ!!!!
Simulator Screen Shot - iPhone 8 - 2019-12-16 at 21.45.52.png

もう一声ェェ!!!!!
Simulator Screen Shot - iPhone 8 - 2019-12-16 at 21.51.52.png

意外といい感じ!!www

Merry Christmasの文字はフリー素材を使わせていただきました!
描画で描こうと思いましたが時間がなかったorz(公開前日に記事を書いてます
それとツリーも本当はもっとクリスマスツリーっぽいものを描画したかったけど
時間が(ry
だれかもっとちゃんとしたクリスマスツリーを25日までに描いてくれー(他力本願

とまあ、めっちゃ中途半端な感じやけど
最後まで見てくれてありがとうございます!!
では 良いクリスマスを!!
(プレゼントにコードを置いていきます!)

追記

関連記事にシダの木を書いてる記事があったから
それを真似インスパイアしてまた挑戦したいな(´;ω;`)www

TreeView.swift
extension CGFloat {
  func degrees_to_radians() -> CGFloat {
    return CGFloat(Double.pi) * self / 180.0
  }
}

extension Double {
  func degrees_to_radians() -> Double {
    return Double(Double.pi) * self / 180.0
  }
}

class TreeView: UIView {

    func drawTree(x1: CGFloat, y1: CGFloat, angle: CGFloat, depth:Int){
        if depth == 0 {
            return
        }

        let ang = angle.degrees_to_radians()
        let x2:CGFloat = x1 + ( cos(ang) as CGFloat) * CGFloat(depth) * (self.frame.width / 60)
        let y2:CGFloat = y1 + ( sin(ang) as CGFloat) * CGFloat(depth) * (self.frame.width / 60)

        let line = self.drawLine(x1: x1, y1: y1, x2: x2, y2: y2)
        line.stroke()

        self.drawTree(x1: x2, y1: y2, angle: angle - 20, depth: depth - 1)
        self.drawTree(x1: x2, y1: y2, angle: angle, depth: depth - 1)
        self.drawTree(x1: x2, y1: y2, angle: angle + 20, depth: depth - 1)

        if depth < 4 && self.randomBool(percent: 5){
            let circle = UIBezierPath(arcCenter: CGPoint(x: x1, y: y1), radius: 3, startAngle: 0, endAngle: CGFloat(Double.pi)*2, clockwise: true)
            UIColor.yellow.setFill()
            circle.fill()
        } else if depth < 4 && self.randomBool(percent: 5) {
            let circle = UIBezierPath(arcCenter: CGPoint(x: x1, y: y1), radius: 3, startAngle: 0, endAngle: CGFloat(Double.pi)*2, clockwise: true)
            UIColor.red.setFill()
            circle.fill()
        } else if depth < 4 && self.randomBool(percent: 5) {
            let circle = UIBezierPath(arcCenter: CGPoint(x: x1, y: y1), radius: 3, startAngle: 0, endAngle: CGFloat(Double.pi)*2, clockwise: true)
            UIColor.blue.setFill()
            circle.fill()
        }
    }

    func drawSnow() {
        let circle = UIBezierPath(arcCenter: CGPoint(x: x1, y: y1), radius: 3, startAngle: 0, endAngle: CGFloat(Double.pi)*2, clockwise: true)
        UIColor.red.setFill()
        circle.fill()

    }

    func drawLine(x1:CGFloat, y1:CGFloat, x2:CGFloat, y2:CGFloat) -> UIBezierPath
    {

        let path = UIBezierPath()
        path.move(to: CGPoint(x: x1,y: y1))
        path.addLine(to: CGPoint(x: x2,y: y2))
        path.lineWidth = 0.5
        return path
    }

    override func draw(_ rect: CGRect) {

        let color = UIColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0)
        color.set()

        self.drawTree(x1: self.frame.width / 2 , y1: self.frame.height * 0.8, angle: -90 , depth: 9 )
    }

    func randomBool(percent: Int) -> Bool {
        return arc4random_uniform(100) < percent
    }    
}


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

iOSエンジニアがWebエンジニアとペアプロでWebを実装した感想

はじめに

GameWith Advent Calendar 2019の19日目の記事になります。

今回の記事はiOSエンジニアの自分がWebの新施作をWebエンジニアとペアプロをしながら実装した時の話です。
自分は普段GameWithの価値検証チームという少人数のチームで、アプリやWebの大幅な数値改善が見込めそうな施作をメインに企画・進行・実装まで一通り担当しています。

基本的に価値検証フェーズと本実装フェーズで分けて実装しており、価値検証フェーズにて目標数値に達したもののみ本実装フェーズに移行しています。
価値検証フェーズでは片方のみに展開することでUXが下がるといった施作以外は基本的にはスピード重視と施作を撃てる数を増やすためにiOS・Androidのどちらかのみで実装しています。

今回の施作はコンテンツの性質などから両OSで実装するのがベターとの判断になったので、Webで実装したものを両OSのWebViewで表示するといった形式を取っています。

Webの知識

自分は去年、Vue.js(+Element UI)Firebaseを使い、GameWithアプリとは別に担当していた動画アプリの管理画面を0から実装しましたが、Webの技術領域にはあまり明るくなく正直 JavaScript, HTML, CSSとかも普段全然触らないので当時勉強し直しながら作りました。

それから1年以上経っていたのと、今回はGameWith Web 内の新機能だったので、ドメイン知識もそこまでなかったため、要所要所でペアプロという形式を取り進めることにしました。

去年のブログ
Firebaseをプロダクトに採用して得た知見 #GameWith #TechWith

ペアプロ

エンジニアの方には馴染みがある言葉だと思いますが、ペアプロの定義を引用して貼っておきます。

ペアプログラミング(英: pair programming)は、2人のプログラマが1台のワークステーションを使って共同でソフトウェア開発を行う手法という説明が起源である。(wikipedia)

上記は社内でペアブログ(共同でブログを執筆)していた時の写真なのですが、上記のように基本的にはひとつのモニターを見ながら進めていきました。
ペアプロでは実際にキーボードを操作してコードを書く人を「ドライバ」、もう1人を「ナビゲータ」と呼ぶのですが、自分はドライバの役割でした。

進め方

まずWeb側の開発環境の準備・整備など基本的には、先に社内の手順ドキュメントを見ながら済ませておきました。

今回のペアプロでは基本的には、1日30分〜最大2時間を目安に行っていました。

1日1画面の実装を目標に、まずペアプロで大枠の進め方の確認。
他の画面のコードの書き方などを参考にしながら、なぜこのように書くのか、なぜこのような使い方をしているかなど社内独自のルール的なものまで共有をしてもらいました。

ペアプロが終わった後は一人で基本的に作業を行い、次の日に前日生じた疑問などを質問したりして、疑問を解消してからまた、実装画面の進め方を決めるなどの作業を繰り返しました。

ペアプロのメリット

ペアプロを大体一週間ほどして以下のメリットを感じました。

(※ ドライバとナビゲータ間のスキル度合いが同等の場合、また別のメリットがあるかとは思いますが、今回はWebのスキルに差がある二人でのペアプロでの効果例です)

作業効率の向上

普段の一人で開発を行う業務よりも集中力が維持できました。
個人個人で集中できる時間の範囲は異なるとは思うのですが、一度だけ2時間がっつりペアプロを行った日に終始プロジェクトに向き合い、頭を使い、話し合いながらコードを書いたため終わった後に疲れて軽く頭が痛くなるくらいの集中でした。

実際にペアプロでは個人の作業よりも怠けることなく作業を進める可能性が上がるといった規範意識の増大の効果が見込めるそうです。

効率の良い教育的側面

個人で勉強し、調べながらでも実装を行うことはできますが、ナビゲータがいることによって設計の質が向上したり、コードレビューをリアルタイムで受けながらコードを書くことができるので、素早く学習することができます。
コードの正確性の向上もそうですが、知識や技術の共有をペアプロを通して効率よく受けることができました。

終わりに

今回、新機能を一通りWebで実装したことで、今後新しいWebの施作が価値検証チーム内で上がってもiOSエンジニアの自分が担当できるなといった自信がつきました。
また今回のペアプロを通してWeb技術への興味が以前よりも湧いたことも大きな収穫です。

[iOS][Swift]JavaScriptと相互通信といった内容を両者自分で実装することでより深くWebとiOSの連携についても理解することができました。

今回は教育的側面の強いペアプロでしたが、非常に有意義な時間だったので、またスキルが同等なiOSエンジニア同士でiOSアプリ実装のペアプロもやってみたいなと思いました。

ナビゲーターをやってくれたtiwu_officialさんありがとうございました!

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

iOSエンジニアがWebをペアプロで実装してみた話

はじめに

GameWith Advent Calendar 2019の19日目の記事になります。

今回の記事はiOSエンジニアの自分がWebの新施作をフロントエンジニアとペアプロをしながら実装した時の話です。
自分は普段GameWithの価値検証チームという少人数のチームで、アプリやWebの大幅な数値改善が見込めそうな施作をメインに企画・進行・実装まで一通り担当しています。

基本的に価値検証フェーズと本実装フェーズで分けて実装しており、価値検証フェーズにて目標数値に達したもののみ本実装フェーズに移行しています。
価値検証フェーズでは片方のみに展開することでUXが下がるといった施作以外は基本的にはスピード重視と施作を撃てる数を増やすためにiOS・Androidのどちらかのみで実装しています。

今回の施作はコンテンツの性質などから両OSで実装するのがベターとの判断になったので、Webで実装したものを両OSのWebViewで表示するといった形式を取っています。

Webの知識

自分は去年、Vue.js(+Element UI)Firebaseを使い、GameWithアプリとは別に担当していた動画アプリの管理画面を0から実装しましたが、Webの技術領域にはあまり明るくなく正直 JavaScript, HTML, CSSとかも普段全然触らないので当時勉強し直しながら作りました。

それから1年以上経っていたのと、今回はGameWith Web 内の新機能だったので、ドメイン知識もそこまでなかったため、要所要所でペアプロという形式を取り進めることにしました。

去年のブログ
Firebaseをプロダクトに採用して得た知見 #GameWith #TechWith

ペアプロ

エンジニアの方には馴染みがある言葉だと思いますが、ペアプロの定義を引用して貼っておきます。

ペアプログラミング(英: pair programming)は、2人のプログラマが1台のワークステーションを使って共同でソフトウェア開発を行う手法という説明が起源である。(wikipedia)

上記は社内でペアブログ(共同でブログを執筆)していた時の写真なのですが、上記のように基本的にはひとつのモニターを見ながら進めていきました。
ペアプロでは実際にキーボードを操作してコードを書く人を「ドライバ」、もう1人を「ナビゲータ」と呼ぶのですが、自分はドライバの役割でした。

進め方

まずWeb側の開発環境の準備・整備など基本的には、先に社内の手順ドキュメントを見ながら済ませておきました。

今回のペアプロでは基本的には、1日30分〜最大2時間を目安に行っていました。

1日1画面の実装を目標に、まずペアプロで大枠の進め方の確認。
他の画面のコードの書き方などを参考にしながら、なぜこのように書くのか、なぜこのような使い方をしているかなど社内独自のルール的なものまで共有をしてもらいました。

ペアプロが終わった後は一人で基本的に作業を行い、次の日に前日生じた疑問などを質問したりして、疑問を解消してからまた、実装画面の進め方を決めるなどの作業を繰り返しました。

ペアプロのメリット

ペアプロを大体一週間ほどして以下のメリットを感じました。

(※ ドライバとナビゲータ間のスキル度合いが同等の場合、また別のメリットがあるかとは思いますが、今回はWebのスキルに差がある二人でのペアプロでの効果例です)

作業効率の向上

普段の一人で開発を行う業務よりも集中力が維持できました。
個人個人で集中できる時間の範囲は異なるとは思うのですが、一度だけ2時間がっつりペアプロを行った日に終始プロジェクトに向き合い、頭を使い、話し合いながらコードを書いたため終わった後に疲れて軽く頭が痛くなるくらいの集中でした。

実際にペアプロでは個人の作業よりも怠けることなく作業を進める可能性が上がるといった規範意識の増大の効果が見込めるそうです。

効率の良い教育的側面

個人で勉強し、調べながらでも実装を行うことはできますが、ナビゲータがいることによって設計の質が向上したり、コードレビューをリアルタイムで受けながらコードを書くことができるので、素早く学習することができます。
コードの正確性の向上もそうですが、知識や技術の共有をペアプロを通して効率よく受けることができました。

終わりに

今回、新機能を一通りWebで実装したことで、今後新しいWebの施作が価値検証チーム内で上がってもiOSエンジニアの自分が担当できるなといった自信がつきました。
また今回のペアプロを通してWeb技術への興味が以前よりも湧いたことも大きな収穫です。

[iOS][Swift]JavaScriptと相互通信といった内容を両者自分で実装することでより深くWebとiOSの連携についても理解することができました。

今回は教育的側面の強いペアプロでしたが、非常に有意義な時間だったので、またスキルが同等なiOSエンジニア同士でiOSアプリ実装のペアプロもやってみたいなと思いました。

ナビゲーターをやってくれたtiwu_officialさんありがとうございました!

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

[サーバーレス]UnityとFirebaseでアプリのアップデートをせずに、動作と外観の変更を行う

この記事は、鹿児島大学生 Advent Calendar 2019の21日目の記事です。

概要

以前、クライアントからの要件でサーバーを準備せずにアプリ内の表示を変更したいという要望があったので、その時に行なった対応と、UnityとFirebaseの連携を簡単に解説します。

今回はできるだけシンプルな状態で実装まで行おうと思います。
上手く活用すると、アプリの背景画像の変更を行なったり、広告表示の頻度もデータをみながら即時反映することができます。
またFirebaseとの連携でA/Bテストへの応用も簡単に行うことができます。

環境

  • iMac Catalina 10.15.1
  • Unity 2019.3.0f3
  • Firebase for Unity 6.8.1

Catalinaだと訳あってUnity2019.2系が上手く動いてくれないのでまだ正式リリースされていない2019.3系で行います。。。
UIが変わったのは個人的には好きです。

セットアップ

Firebaseは、Googleのプラットフォームでいくつものサービスがありますが
今回はRemote Configを使用していきます。
スクリーンショット 2019-12-18 21.17.27.png

Unityのプロジェクト構築

まずはサンプルプロジェクトを作成していきます。
中身はなんでもいいのですが、今回は2DプロジェクトでFirebaseからデータを取得するボタンと取得した文言を表示するテキストを準備します。
こちらのやり方は、Unity ボタンとかUnity Textなどで調べるとすぐにわかるので今回は割愛します。
スクリーンショット 2019-12-18 21.21.54.png

Firebaseプロジェクトを追加

Firebaseを使用するため、Firebaseコンソールからプロジェクトを追加していきます。
スクリーンショット 2019-12-18 21.26.40.png

手順2でのアナリティクスは有効にすることで他のFirebaseのサービスと連携することができるのですが、
今回はできるだけシンプルに考えられるようにしたいので、Offにします。
実際は有効にして使うことがほとんどです。
スクリーンショット 2019-12-18 21.28.51.png

Unityアイコンから構成ファイルの作成を行なっていきます。
スクリーンショット_2019-12-18_21_30_25.png

Register as iOS appとRegister as Android appにチェックを入れてバンドルIDとパッケージ名を登録していきます。
ここは後から変更ができないので、先にストアの作成からしてバンドルIDとパッケージ名を確定しておいた方がいいかもしれません。
スクリーンショット 2019-12-18 21.44.09.png

構成ファイルがダウンロードできるので、指示通りダウンロードしてUnityのAssets直下にDDで追加します。
構成ファイルはAssets直下であればSDKがよしなに読み取ってくれるので任意のフォルダに整理しても大丈夫です。
スクリーンショット 2019-12-18 21.52.12.png

SDKをダウンロードしインポートします。
スクリーンショット 2019-12-18 21.55.11.png

zipを解凍したらdotnet3dotnet4のフォルダが作成されます。
Unity 5.0以前では、.NET 3.x フレームワークを使用しているため、dotnet3フォルダのpackageを使用します。
Unity 2017.x以降では.NET 4.x フレームワークを使用できるため.NET 4.xを使用する場合はdotnet4フォルダのpackageを使用します。
FirebaseRemoteConfig.unitypackageをダブルクリックで開いているUnityプロジェクトにインポートできます。
スクリーンショット 2019-12-18 22.00.07.png
スクリーンショット 2019-12-18 22.08.05.png

インポートまで完了したらセットアップは完了です。

実装

Android 向け Firebase Unity SDK には Google Play 開発者サービスが必要であり、SDK を使用する前に最新版にしておく必要があります。
アプリケーションの先頭に次のコードを追加します。
SDK で他のメソッドを呼び出す前に Google Play 開発者サービスを確認し、必要であれば、Firebase Unity SDK で必要とされるバージョンに更新するようにします。

初期化

    void Start()
    {
        Firebase.FirebaseApp.CheckAndFixDependenciesAsync().ContinueWith(task =>
        {
            var dependencyStatus = task.Result;
            if (dependencyStatus == Firebase.DependencyStatus.Available) {
                // 使用する準備が整った時に通る
            } else {
                Debug.LogError(String.Format("すべてのFirebase依存関係を解決できませんでした: {0}", dependencyStatus));
            }
        });
    }

デフォルトパラメータ値の設定

Remote Config オブジェクトにデフォルトのパラメータ値を設定しておくと、Firebaseから値を取得できない場合にデフォルト値を使用するようにできます。
これにより、オフラインなどの状況でもアプリがクラッシュしないようにできます。

      // リモート設定を初期化し、デフォルト値を設定します。
    void InitializeRemoteConfig() {
        Dictionary<string, object> defaults = new Dictionary<string, object>();
        defaults.Add("remote_string_value", "ローカルの文字です。");
        FirebaseRemoteConfig.SetDefaults(defaults);
    }

パラメータ値の取得

Firebaseからデータをフェッチすることでデータを取得できます。

    //フェッチリクエストを開始します。
    public Task FetchDataAsync() {
      Debug.Log("データを取得しています...");
      System.Threading.Tasks.Task fetchTask = Firebase.RemoteConfig.FirebaseRemoteConfig.FetchAsync(
          TimeSpan.Zero);
      return fetchTask.ContinueWithOnMainThread(FetchComplete);
    }

    void FetchComplete(Task fetchTask) {
      if (fetchTask.IsCanceled) {
          Debug.Log("フェッチはキャンセルされました。");
      } else if (fetchTask.IsFaulted) {
          Debug.Log("フェッチでエラーが発生しました。");
      } else if (fetchTask.IsCompleted) {
          Debug.Log("フェッチが正常に完了しました!");
      }

      var info = Firebase.RemoteConfig.FirebaseRemoteConfig.Info;
      switch (info.LastFetchStatus) {
        case Firebase.RemoteConfig.LastFetchStatus.Success:
          Firebase.RemoteConfig.FirebaseRemoteConfig.ActivateFetched();
          Debug.Log(String.Format("リモートデータがロードされ、準備完了 (最終取得時間{0}",
                                 info.FetchTime));
          break;
        case Firebase.RemoteConfig.LastFetchStatus.Failure:
          switch (info.LastFetchFailureReason) {
            case Firebase.RemoteConfig.FetchFailureReason.Error:
              // 不明な理由でフェッチに失敗しました
              break;
            case Firebase.RemoteConfig.FetchFailureReason.Throttled:
              Debug.Log( info.ThrottledEndTime + "まで取得を抑制");
              break;
          }
          break;
        case Firebase.RemoteConfig.LastFetchStatus.Pending:
          Debug.Log("最新のフェッチ呼び出しはまだ保留中です。");
          break;
      }
    }

パラメータ値を使用するにはGetBalue()関数を使用します。

var value = Firebase.RemoteConfig.FirebaseRemoteConfig.GetValue("remote_string_value").StringValue;

これらを使用して、ボタンを押したらフェッチを行います。
先ほど作成したテキストに状態を表示できるようにしていきます。
Debug.LogをすべてDebugLogに置き換えることで表示を更新できるようにします。

 //デバッグログテキストフィールドとコンソールにテキストを出力します。
    public void DebugLog(string s)
    {
        uiText.text = s;
    }

あとはこれらをUnityに設定していきます。

動作確認

33527-pabho.gif

このままだと、まだデフォルトしか表示されていないので次にFirebase側で更新したいパラメータを設定していきます。
先ほど作成したFirebaseプロジェクトからRemote Configの管理画面を開きます。
スクリーンショット 2019-12-18 22.58.13.png

管理画面から、簡単にパラメータの追加が行えるので
先ほどプログラムで引数に与えたremote_string_valueのキーで設定していきます。
追加できたら右上の変更を公開を押して動作確認してみてください。
スクリーンショット 2019-12-18 22.59.37.png

さいごに

とても簡単にサーバーレスで即時にアプリの変更をすることができましたね。

ストアの規約上、なんでも変更していいというわけではないですが
A/Bテストなどではとても力を発揮すると思いますので是非使ってみてください。

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

iOS Simulator(iPhone Simulator)をMacにインストールする方法

この記事は

Screenshot from Gyazo
MacではXcodeをインストールするとiPhoneやApple WatchのSimulatorを使うことができます。
この記事はその設定方法を書きました。

手順

① Xcodeのインストール

App Store、またはDevelopperサイトからXcodeをインストールします。
App Store
Developerサイト

② Simulatorのインストール

(※結構時間かかります & 1つあたり2.0 ~ 3.5GBぐらいあります)
1. Xcodeを起動する
2. メニューバー > Xcode > Preferenceをクリックする
3. Componentsタブを開く
4. 必要なiOSバージョンをクリックしてインストールする

image
image

③ Simulatorの起動

  1. Xcodeを起動する
  2. メニューバー > Xcode > Open Developer Tool > Simulatorをクリックする
    ※ SimulatorはDockに追加できるので、入れておくと便利かも。 image

④ 複数のSimulatorや、デバイス/OSを切り替えたい場合。

  1. Simulatorを起動する
  2. メニューバー > Hardware > Device 選ぶ
  3. iOS一覧とHardware一覧が表示されるので、使いたいものをクリックする
    ・ PCのSpecにもよりますが、同時起動は2 ~ 3台がよいかと。
    ・表示言語を日本語にしたりも可能です。Client証明書のインストールもできるし、もちろんSafariも使えます。 image

合わせて

・iOS バージョンと Safariバージョンの対応表
 https://qiita.com/yoshitake_1201/items/05a13fd77c18ff380eb6
Safariをつかいたけど、どのiOSを入れるか迷う方は↑を参考に入れてみると良いかもです。
WebKitが変わったり、SafariのMajor Versionが変わっているやつを入れたほうが容量的に優しいですね。

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

XCTest+αでJSON形式のテストデータを使い倒す

テストを拡充させていくにあたり,検証に都合のよいデータを手軽に用意できるようにした作業内容のメモ.
サンプルコード→gaussbeam/TestDataloader

やりたいこと

  • あらかじめ用意しておいた.jsonファイルと型を指定することで,jsonファイル内のデータをもとに必要な形式のデータを取得できるようにする

なぜやりたいのか

  1. テストコード内でinit()すると,テスト自体とは直接関わりのないコードが増え,テストの内容が把握しづらくなる
  2. そもそもinit(from decoder: Decoder)しか実装していないようなオブジェクトの場合,コードからのテストデータ生成のために別途イニシャライザを定義する必要がある
  3. 異なるテストケースで同様のデータを利用する場合,その都度データ生成用のコードを書くのは冗長

やったこと

以下に示す方法でjsonファイルから任意の型のテストデータを生成できるようにした.

1. Loader Protocolを定義

このProtocolにより,バンドル内のファイル取得→Dataまでの変換を共通的に行うようにした.
また,変換後の型を任意のものにできるよう,associatedtypeを指定させるようにしている.

実装は以下の通り.

protocol Loader {
    associatedtype DataType

    var bundle: Bundle { get }
    func load(resourceName: String) -> DataType
    func loadData(resourceName: String) -> Data
    func convert(data: Data) -> DataType
}

extension Loader {
    var bundle: Bundle {
        return Bundle(identifier: "jsonファイルを含んだバンドルのID")!
    }

    func load(resourceName: String) -> DataType {
        let data = loadData(resourceName: resourceName)
        return convert(data: data)
    }

    func loadData(resourceName: String) -> Data {
        let path = bundle.path(forResource: resourceName, ofType: "json")!
        let url = URL(fileURLWithPath: path)
        return try! Data(contentsOf: url)
    }
}

Loaderは以下のような流れでjsonからオブジェクトへの変換を行う.

  1. 所定のバンドル内から,指定されたjsonファイルを取得
  2. 取得したjsonファイルの内容をData型に変換
  3. Data型からDataType型(associatedtypeとして紐付け)に変換
  4. 変換されたDataType型のオブジェクトを返す

上記の処理について,1~4までの全体の流れはload()メソッドの,1~2はloadData()としてデフォルト実装としてそれぞれ定義している.
3の変換処理についてはconvert()が該当するが,この処理(およびDataTypeの指定)については,Loaderの適合先でそれぞれ実装させることで,任意の型への変換ができるようにした.

2. 型ごとのLoaderを定義

JSON(=[String: Any])とDecodableに適合したオブジェクト用のLoaderを定義する場合,以下のようになる.

/// jsonファイルから取得した内容を`[String: Any]`に変換するLoader
struct JsonLoader: Loader {
    typealias DataType = [String: Any]

    func convert(data: Data) -> DataType {
        return try! JSONSerialization.jsonObject(with: data, options: []) as! DataType
    }
}

/// jsonファイルから取得した内容を`Decodable`に適合した任意の型に変換するLoader
struct DecodableObjectLoader<T: Decodable>: Loader {
    typealias DataType = T

    func convert(data: Data) -> DataType {
        return try! JSONDecoder().decode(DataType.self, from: data)
    }
}

上記の例以外にも,Dataから変換可能な型はLoaderを定義できる.
例えば,JSONのパースにObjectMapperを使っているような場合,以下のように定義できる.

struct MappableObjectLoader<T: Mappable>: Loader {
    typealias DataType = T

    func convert(data: Data) -> DataType {
        let jsonString = String(data: data, encoding: .utf8)!
        return DataType(JSONString: jsonString)!
    }
}

3. Loaderとテストデータをプロジェクトファイル内に組み込み,テストを記述.

Loaderとテストデータ,およびテストコードを以下のように配置することで,XCTestで利用可能になる.
※ Frameworkを分けている場合に,テストデータやLoaderを共通化したい場合は注意が必要(これはそのうち別記事で書く)
スクリーンショット 2019-12-18 17.11.45.png

Loaderによるテストデータの取得(およびテストを実行)例は以下の通り.
(詳細はサンプルコードを参照)

class CouponTests: XCTestCase {
    func testIsUsed() {
        // ジェネリクスでデコード先の型を指定
        let loader = DecodableObjectLoader<Coupon>()

        XCTContext.runActivity(named: "usedDate==nilの場合,isUsedはfalse") { _ in
            // 引数としてテストデータのファイル名を指定
            let c = loader.load(resourceName: "unusedCoupon")
            XCTAssertFalse(c.isUsed)
        }

        XCTContext.runActivity(named: "usedDate!=nilの場合,isUsedはtrue") { _ in
            let c = loader.load(resourceName: "usedCoupon")
            XCTAssertTrue(c.isUsed)
        }
    }
}

なお,上記のコードで使用したオブジェクトの定義およびjsonファイルは以下の通り.

// (参考)Loader経由で取得するオブジェクトの型
struct Coupon: Decodable {
    var id: Int
    var title: String
    var usedDate: Date?
    var isUsed: Bool {
        return usedDate != nil
    }
}
unusedCoupon.json
{
    "id": 123,
    "title": "Some coupon",
    "usedDate": null
}

APIのモックに利用する

上記の例ではCoupon型にデコードした値のアサーションを行っているが,別の用法として[String: Any]としてデコードした結果をAPIのモックに利用することも可能.

例:Mockingjayと組み合わせた場合

class SomeApiTests: XCTestCase {
    func testSomeApi() {
        
        stubHttp(invitationRequest) { request in
            let body = JsonLoader().load(resourceName: "usedCoupon")
            return json(body, status: 200)(request)
        }
        
        // usedCoupon.jsonの内容で書き換えたAPIレスポンスに対するテスト
    }
}

やってみて

よかったこと

  1. テストデータ生成を1行で行えるようになった
    • 当初の動機であった「テスト自体とは直接関わりのないコードの増加を避ける」ことができた.
      また,所定のBundle内にあるファイルからData型で取得しそれを変換し…というプロセスもLoader内に集約したので,テストコード自体の見通しが良くなった.
  2. 1つのデータをいろいろな形式で使えるようになった
    • 例で挙げたように,Decodableに適合したオブジェクトやJSON(=[String: Any])など任意の型で取得できるので,異なる観点・やり方のユニットテストにおいて,1つのjsonファイルを使い回せるようになった.
  3. レスポンスの書き換えにも利用できる
    • Charlesと組み合わせることで,APIレスポンスの書き換えにも利用できる.
      当初は想定していなかったことだが,ユニットテストのみならず,デバッグ実行時の振る舞い確認においても任意の状況を作ることができ,より開発がしやすくなった.

改善したいこと

  1. テストデータの置き場
    • 先にも少し触れたが,特定のテストターゲット内にバンドルしているため,Frameworkを分けている場合にテストデータを共用できない.
      • :pencil: Kickstarterでは共用Framework(Library)の中にTestHelperというグループを作り,Frameworkをまたいでテスト用のヘルパを共有している
        • この方法をそのまま使うとテストデータもアプリ本体にバンドルされてしまうのでもうひと工夫必要になりそう :thinking:
  2. Bundleの取得方法
    • Bundle自体もIdentifierをベタ書きしているのでポータビリティが低い(一箇所指定するだけなのでそこまで実害はないが…).
      • これについては,Extensionのデフォルト実装でやっていることに起因しているので,役割を分けてBundleを取得するクラスを使うのがよさそう.

「もっとうまいやり方あるよ!」という場合にはコメントにてお願いします :pray:

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

UIScrollViewを使ってウォークスルーを作る方法

メリークリスマスイブ!初めまして,テシマ(@tessy_0901)と申します.
皆さんはどんなクリスマスをお過ごしになりますか?私はバイトです.
というわけで iOS#2 AdventCalender2019,24日目の記事となります.

こちらではPageViewControllerとボタンを使ったウォークスルーの実装を書きましたが,スクロールビューを使って同じ動作をさせたいと思います.
先駆者様の最高の記事がめちゃくちゃ多いため,適宜リンクを貼って省略させていただきます.

PageViewControllerの代わりにScrollViewを使うメリットとしては,

  • StoryBoard内のViewControllerの1画面で完結する
  • PageControlの位置を変更しやすい(←ここがでかい)
  • viewのinstanceを生成しないため,循環参照が起きる可能性がない
  • swiftファイルの数を1つにできる

などがあります.(実際にこっちの方が簡単だった)
また,Xcode11からはScrollViewをXcode上でスワイプさせて確認することができるようになったため,こちらを使う方がベターだと感じました.

では本文です.

この記事でできること

  • UIViewControllerでPageControlができる
  • ボタンでページ移動ができる

完成品

Unknown.gif
GitHub
このようにボタンでもスワイプでもページ移動ができるような物を作っていきたいと思います.

実装

UIPageViewControllerの実装

project作成

省略します!(上にあるPageControllerの記事を参考にしたりしなかったりしてください)

UIScrollViewによるPageViewの作成(StoryBoard)

UIScrollViewとPageControlの設置や設定は,この記事参考にしてください(丸投げ)
それとXcode11からUIScrollViewの方式が変わりました.
以前(Xcode10)は,

View
┠Safe Area
┠UIScrollView
┃ ┗View
┃   ┠View1
┃   ┠View2
┃   ┠...
┗PageControl

のように,UIScrollViewの下にScrollView全体を示すViewを置き,その下に各Viewを置く階層構造でしたが,
Xcode11からは

Xcode11
View
┠Safe Area
┠UIScrollView
┃ ┠Content Layout Guide
┃ ┠Frame Layout Guide
┃ ┠View1
┃ ┠View2
┃ ┠...
┗PageControl

このように,Content Layout GuideFrame Layout Guideという要素が追加され,それぞれが全てのViewのガイド,描画範囲のガイドとなりました,
詳しくはこちらこちらをご覧ください.
ここからが大変なのですが,上記の記事にもあるように,AutoLayoutの設定が変わりました.

AutoLayoutの設定

完成形はこうなります(白目)
スクリーンショット 2019-12-18 20.09.46.png

ちゃんと一つ一つ説明していきます.
まずView1,View2,View3の制約ですが,

View1.trailing = View2.leading
View2.trailing = View3.leading

として,View1の右端にView2の左端,View2の右端にView3の左端をくっつけます.
次にView1のWidth,HeightをViewのWidth,Heightと等しくします.

次にContent Layout Guideの設定です.

View1.top = ContentLayoutGuide.top
View2.top = ContentLayoutGuide.top
View3.top = ContentLayoutGuide.top
View1.buttom = ContentLayoutGuide.button
View2.buttom = ContentLayoutGuide.buttom
View3.buttom = ContentLayoutGuide.buttom
View1.leading = ContentLayoutGuide.leading
View3.trailing = ContentLayoutGuide.trailing

全てのviewのtop,buttomをContent Layout Guideのtop,buttomと合わせて,
leading,trailingはそれぞれ端のview(leading = View1, trailing = View3)と合わせます.

最後にView1のheight, widthを大元のViewのheight, widthに合わせ,

View1.height = height
View1.width = width
View2.height = View1.height
View2.width = View1.width
View3.height = View1.height
View3.width = View1.width

View1=View2=View3と合わせることで,どの端末でも画面の大きさが変わらないようにしました.
これで画面は完成です.

UIScrollViewによるPageViewの作成(ソースコード)

追加したScrol, PageControllをBIOutletとして, ButtonをIBActionとしてそれぞれつなぎます.
そしてScrollViewのdelegateを設定するとこうなります.

ViewController
import UIKit

class ViewController: UIViewController {
    @IBOutlet weak var pageScrollView: UIScrollView!
    @IBOutlet weak var pageControl: UIPageControl!
    @IBAction func onButtonTapped(_ sender: Any) {
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.pageScrollView.delegate = self
    }
}

ここまでできたら中身を書いていきましょう


ボタンによる遷移の実装

とりあえず完成したソースコード載せます

ViewController
import UIKit

class ViewController: UIViewController {
    @IBOutlet weak var pageScrollView: UIScrollView!
    @IBOutlet weak var pageControl: UIPageControl!
    @IBAction func onButtonTapped(_ sender: Any) {
        let contentSize: CGSize = self.pageScrollView.contentSize
        let currentPage: Int = pageControl.currentPage
        pageScrollView.setContentOffset(CGPoint(x: floor(contentSize.width/3 * CGFloat(currentPage + 1)), y: 0), animated: true)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.pageScrollView.delegate = self
    }

}

extension ViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView:UIScrollView) {
        let pageControlNumber = pageScrollView.contentOffset.x / pageScrollView.frame.size.width
        if pageControlNumber.truncatingRemainder(dividingBy: 1) == 0 {
            pageControl.currentPage = Int(pageControlNumber)
        }

    }
}
@IBAction func onButtonTapped(_ sender: Any) {
        let contentSize: CGSize = self.pageScrollView.contentSize
        let currentPage: Int = pageControl.currentPage
        pageScrollView.setContentOffset(CGPoint(x: floor(contentSize.width/3 * CGFloat(currentPage + 1)), y: 0), animated: true)

ここではボタンを押した際に,ScrollView全体の中身のサイズを取得して,画面サイズxPageControl+1だけずらすという動作にしています.

extension ViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView:UIScrollView) {
        let pageControlNumber = pageScrollView.contentOffset.x / pageScrollView.frame.size.width
        if pageControlNumber.truncatingRemainder(dividingBy: 1) == 0 {
            pageControl.currentPage = Int(pageControlNumber)
        }

    }
}

また,UIScrollのdelegateを指定しているためスクロールのオフセットの変化を検知するメソッドを呼び出せます.
そのためそのメソッドで,currentPageをスクロールが終わった際(→ Scrollのオフセット/画面の横幅 が整数の時)にcurrentPageが与えられるようにしています.

最後に

流石に雑すぎるので追記でもっと丁寧に解説します・・・
それと弊部のアドカレもよろしくお願いします!!!!!!!

参考文献

https://techblog.recochoku.jp/7237
https://program-life.com/538
https://developer.apple.com/documentation/uikit/uiscrollview/2865772-framelayoutguide
https://developer.apple.com/documentation/uikit/uiscrollview/2865870-contentlayoutguide

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

react-native-maps使おうとしたらAIRMapが無いって怒られた

環境

$ react-native -v
react-native-cli: 2.0.1
react-native: 0.61.5

状況

react-native-mapsを使ってiOSで地図を表示させたい。
でも、react-native run-iosで起動してみたらエラー出てしまったので解消したい。

run-iosまでにやったこと

$ npm install react-native-maps    // react-native-mapsをインストール
$ react-native link react-native-maps // react-native-mapsをプロジェクトと紐付け

そして下の記事を参考にしてApp.jsをちょちょっと書き換えてあげました。

(参考)https://qiita.com/megane42/items/a53db54c0aa64647c6e3

エラー内容

スクリーンショット 2019-12-19 6.58.59.png

Invariant Violation: requireNativeComponent:
"AIRMap" was not found in the UIManager.

This error is located at:
 in AIRMap (at MapView.js993)
 in MapView (at App.js:34)
 in App (at renderApplication.js:40)
 in RCTView (at AppComtainer.js:101)
 in RCTView (at AppContainer.js:119)
 in AppContainer (at renderApplication.js:39)

AIRMapが無いんだけどどうしてくれんだ、ってエラーが出てますね。
どうしてくれましょうか。

解決策

ネットで検索してもなかなか情報がなかったので、とりあえずもう一回起動してみたところ、なにやらエラーが出ていました。。。

$ react-native run-ios
error React Native CLI uses autolinking for native dependencies, but the following modules are linked manually: 
  - react-native-maps (to unlink run: "react-native unlink react-native-maps")
This is likely happening when upgrading React Native from below 0.60 to 0.60 or above. Going forward, you can unlink this dependency via "react-native unlink <dependency>" and it will be included in your app automatically. If a library isn't compatible with autolinking, disregard this message and notify the library maintainers.
Read more about autolinking: https://github.com/react-native-community/cli/blob/master/docs/autolinking.md
(これ→)error Could not find the following native modules: react-native-maps. Did you forget to run "pod install" ?
info Found Xcode workspace "imhere.xcworkspace"
info Building (using "xcodebuild -workspace imhere.xcworkspace -configuration Debug -scheme imhere -destination id=25C2AEEA-275A-4BF4-840D-398D2A131B47 -derivedDataPath build/imhere")
    Capabilities for Signing & Capabilities may not function correctly because its entitlements use a placeholder team ID. To resolve this, select a development team in the imhere editor. (in target 'imhere' from project 'imhere')
▸ Running script 'Start Packager'
▸ Running script 'Bundle React Native code and images'
▸ Build Succeeded
info Installing "build/imhere/Build/Products/Debug-iphonesimulator/imhere.app"
info Launching "org.reactjs.native.example.imhere"
success Successfully launched the app on the simulator

エラーの箇所を抜粋すると

error Could not find the following native modules:
react-native-maps. Did you forget to run "pod install" ?
(訳)react-native-mapのnativeモジュール見つかんねぇんだけど、お前"pod install"忘れてね?

と書いてありました。

いや、全然してなかったですね。ごめんなさい。

公式にもちゃんとReactNativeのバージョンが0.60以上のときはpod installしろよって書いてありましたね。

(公式)https://github.com/react-native-community/react-native-maps/blob/master/docs/installation.md

というわけで、ネイティブモジュールをインストールしましょう。

$ cd ios
$ pod install
Analyzing dependencies
Downloading dependencies
Installing react-native-maps (0.26.1)
Generating Pods project
Integrating client project
Pod installation complete! There are 29 dependencies from the Podfile and 27 total pods installed.

お、なんだかいい感じにインストールできたっぽいですね。

というわけで再度起動してみると、、、

おー、いい感じに表示されました!!

最後に

Qiitaの記事を参考にして書いていくとたしかに早くプロダクトを作れますが、本家よりもやっぱり情報は削られているので、本当に参考程度にしてちゃんと本家を見ながらすすめるようにした方がよいですね。

あと、今回私のエラーの原因はpod installをしていなかったからでしたが、他の原因ももちろんあると思うので、違った場合には次を参考にしてみてください。

(参考)https://github.com/react-native-community/react-native-maps/issues/2495

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

FeliCa が遅いしフルスキャンも不可能 前編【iOS 13 Core NFC】

気づけば Advent Calendar も後半戦…。
私はこれまで FeliCa を iOS 13 の Core NFC で読み取るサンプルを紹介してきました。

そして、前回の

では iOS での FeliCa の取り扱いの制限(システムの切り替えを IDm で行うことができない)について述べました。

…ですが、現在の iOS 13 での FeliCa に関する制限はまだまだあるのです…。

この記事で紹介する iOS での FeliCa の制限

まとめを先に書いておきます。

  • FeliCa の読み取りで得られる最初の currentSystemCode は Info.plist での FeliCa システムコードの記述する順番に依存する
  • FeliCa の読み取り速度は Info.plist での FeliCa システムコードの記述する順番に依存する
  • FeliCa のフルスキャンを iOS で行いたい場合、「じゃあ Info.plist に存在する全ての FeliCa システムコードを記載すればいいのでは…?」と考えつくが、それは現実的な解決策ではない → 後編につづく

FeliCa の読み取りで得られる最初の currentSystemCode は Info.plist での FeliCa システムコードの記述する順番に依存する

異なる FeliCa システムコードを持つカードを読み取ろうとしてみる

これまでに書いた「楽天Edy、nanaco、WAON」を読み取る方法と、「交通系IC(Suica、PASMO、ICOCA、…etc.)」を読み取る方法で、あらかじめ 使う FeliCa システムコードを Info.plist に追加しておく必要がある と記載してきました。楽天Edy、nanaco、WAON で使われている FeliCa システムコードは 0xFE00、交通系IC で主に使われている FeliCa システムコードは 0x0003 になります。

では、異なる FeliCa システムコードを持つ 楽天Edy と 交通系IC の残高を読み取るための iOS App を開発するにはどうすればいいでしょうか。
Capability と Entitlements の設定は済ませておくとして、まずは使う FeliCa システムコードを Info.plist に追加します。

スクリーンショット 2019-12-18 11.30.44.png

こんな感じで Item 00xFE00Item 10x0003 を指定しました。
そして、NFCTagReaderSessionDelegatetagReaderSession(_:didDetect:) を書いて検出された FeliCa タグに接続、データの読み取る準備をします。

func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
    let tag = tags.first!
    session.connect(to: tag) { (error) in
        if let error = error {
            session.invalidate(errorMessage: error.localizedDescription)
            return
        }

        guard case NFCTag.feliCa(let feliCaTag) = tag else {
            session.invalidate(errorMessage: "FeliCa ではない")
            return
        }

        session.alertMessage = "カードを読み取っています…"

        var serviceCode: Data!
        var startIndex = 0
        var endIndex = 0
        switch feliCaTag.currentSystemCode {
        case Data([0x00, 0x03]):
            // 交通系IC
            serviceCode = Data([0x00, 0x8B].reversed())
            startIndex = 11
            endIndex = 12
            break
        case Data([0xFE, 0x00]):
            // 楽天Edy
            serviceCode = Data([0x13, 0x17].reversed())
            startIndex = 0
            endIndex = 3
            break
        default:
            fatalError()
        }

        let blockList = [Data([0x80, 0x00])]
        feliCaTag.readWithoutEncryption(serviceCodeList: [serviceCode], blockList: blockList) { (status1, status2, blockData, error) in
            if let error = error {
                session.invalidate(errorMessage: error.localizedDescription)
                return
            }

            guard status1 == 0x00, status2 == 0x00 else {
                print("ステータスフラグがエラーを示しています", status1, status2)
                session.invalidate(errorMessage: "ステータスフラグがエラーを示しています s1:\(status1), s2:\(status2)")
                return
            }

            let data = blockData.first!
            let balance = data.toIntReversed(startIndex, endIndex)

            print(data as NSData)
            print("残高: ¥\(balance)")
            session.alertMessage = "残高: ¥\(balance)"
            session.invalidate()
        }
    }
}

こちらに示した tagReaderSession(_:didDetect:) 以外のコードは全て前々回の記事などと同じですので、そちらを参考にしてください。

検出された FeliCa カードの currentSystemCode が交通系ICのものか、楽天Edy のものかによって switch し、それぞれの serviceCode 等を指定しています。

ではビルドして実機で動作を確認してみましょう。

楽天Edy(カード型)を読み取った場合


残高が正しく表示されました。

Suica(Apple Pay)を読み取った場合


カード型でない、Suica が入っている Apple Watch に iPhone を近づけると…これも正しく残高を読み取ることができました。

Suica(カード型)を読み取った場合


では普通のカード型の Suica を読み取ると……と、ステータスフラグがエラー……?

status11status2166 (0xA6)というのは、"サービスコードリスト不正" にあたるので、サービスコードの指定が間違っていることになります。どうして Apple Pay の Suica は読み取れたのに、カードの Suica はダメだったんだろう…?

それぞれが持つ FeliCa システムコードを調べる

では、Japan NFC Reader を用いて Apple Pay の Suica とカード型の Suica が持つ FeliCa システムコードを調べてみましょう。
「その他」にある「IDm・PMm を読み取る」からカードをスキャンすると、入っている FeliCa システムコードを調べることができます。

楽天Edy(カード型)が持つ FeliCa システムコード


カード型の楽天Edyは 0x8B610xFE00 の2つの FeliCa システムコードを持っていることがわかります。

Apple Pay の Suica が持つ FeliCa システムコード


Apple Pay の Suica には確かに 0x0003 があることを確認できますね。

Suica(カード型)が持つ FeliCa システムコード


先ほど 読み取りに失敗したカード型の Suica は 0x0003 の他に… 0xFE00 も持っている!? 1

FeliCa の読み取りで得られる最初の currentSystemCode は Info.plist での FeliCa システムコードの記述する順番に依存する

なぜこのような問題が起きたのでしょうか。
今回、switchcurrentSystemCode が何かによって Suica か 楽天Edy かを判別しました。そして、0x0003 のみを持つ Apple Pay の Suica は currentSystemCode0x0003 であったため、無事に Suica と判別できましたが、カード型の Suica には 0x0003 の他に 0xFE00 も持っていたため、currentSystemCode0xFE00 となり 楽天Edyルートに入ることになってしまいました。

もし、FeliCa システムコードを用いてカードを判別しようとするとき、Info.plist に記述する FeliCa システムコードの順番が鍵となる場合があります。

スクリーンショット 2019-12-18 11.30.44.png

いまは Item 00xFE00Item 10x0003 を指定しました。この順番を逆にします。

スクリーンショット 2019-12-19 5.02.43.png

この Info.plist 以外はコードの変更を行わないまま、ビルドしてもう一度カード型の Suica をスキャンしてみましょう。

無事に正しい残高が表示されました!

FeliCa の読み取り速度は Info.plist での FeliCa システムコードの記述する順番に依存する

PaSoRi などでワイルドカードを使用して FeliCa カードに Polling をした場合、FeliCa に搭載されている 0 番目のシステムが検出されるかと思いますが、iOS 13 の Core NFC の場合は検出された FeliCa カードにあるシステムのうち、Info.plist に記載され 最も順番が前にある FeliCa システムコードに一致するシステム が検出、currentSystemCode および currentIDm にはそれが入ることになります。

そのため、多数の FeliCa システムコードに対応しようとした場合、Info.plist に記載する順番が非常に重要になります。

スクリーンショット 2019-12-19 5.21.09.png

取り上げたサンプルでは、FeliCa システムコードが2つだったので特に問題はありませんでしたが、上記スクリーンショットにある Item 4 の 0x8008 が検出されるまでには前4つ(0xFE000x00030x865E0x8592)に比べて時間がかかります。

これが iOS 13 Core NFC で FeliCa を扱う上でまた足を引っ張ることに………。

後編に続く


  1. Suica や PASMO 等、カードの発行時期などによってカードに含まれる FeliCa システムコードの数が異なります。 

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

iOSが無くなってもエンジニアを続けるためのSwiftUI+MacCatalyst入門

メリークリスマス(イヴ)!!??

この記事は、第二のドワンゴ Advent Calendar 2019 の24日目の記事です。
そして、今日は12/24、クリスマスイヴです。みなさん、いかがお過ごしでしょうか。

この記事で伝えたい事

この記事は、以下についてまとめています。

  • SwiftUI を使って、動画プレーヤー上にコメントを表示、アニメーションさせる方法
  • Mac Catalyst で、 iOS/iPadOSアプリをMacアプリとして動作させる方法(ここはちょっとだけです?)

対象読者は、iOS開発の経験があり、SwiftUIをこれから触っていこうとしている人です。

自己紹介

お仕事では、iOSアプリ開発を担当しています。
現在の私の役割としては、プロジェクトの進行管理や、調整ごとがメインなため、どうしても自分で手を動かすことが減ってしまっています。
しかし、その状況に甘える事なく、継続して新しい技術に触れ、技術的な視野・知識を広げていくことがエンジニアとして必要不可欠だと感じています。
というわけで、今回、エンジニアとしてのお勉強の一環として、最近学習したSwiftUIの基本事項をまとめるという形で、アドベントカレンダーに参加させていただこうと思います。よろしくお願いします ??‍♂️

ちなみに…
自己紹介ついでに、これまで社内LTで発表したものやAppStoreに公開したものについて触れておくと、
顔認識を使って降り注ぐ自分の顔を避けながら進む、ほうれいせん予防の弾幕ゲームアプリ とか、きれいなジャイアンからヒントを得たARKitを使ってイケメンになれるアプリ とか、とてもここでは説明できないようなちょっとおかしなアプリばかりです…
が、社内のエンジニアの皆さんはそんなふざけたテーマでも暖かく受け止めてくれていましたw
…… 大丈夫です。今回は真面目なテーマで書きますので ?

こんな感じでした。
F8E20E62-7FB4-4B5F-8DD2-B2468F2A3F62.jpeg683AA08B-9546-4284-B3FC-11A66AA2C644.jpeg

2019年のiOS開発トピックス

さて、iOSの今年のトピックスといえば、なんといっても、
WWDC2019で Mac CatalystSwiftUI が発表されたことです。

Mac Catalyst は、iPad AppをMac Appでも動かせるようにするという画期的な取り組みです。これに伴い、UIKitやAppKitなどのフレームワークが拡張されました。
SwiftUI は、これまでのStoryBoardやXIBを使わずにswiftコードだけでUIが作れるという仕組みです。iOSやMacなどのUIを一括で作成する際にも SwiftUI が使われます。

Mac Catalyst の理由について、Appleは、iOS/iPadOSのAppStoreには多くのアプリが存在しており、それらの多くのアプリがMacでも使えるようになることで、Macユーザがより便利になり、また、デベロッパにも新たな機会を提供できるとしています。1
この取り組みがうまくいけば、 閑散としている MacAppStoreは活性化して、私たちスマホエンジニアは、デスクトップアプリ開発へと、活躍の幅を広げていけることでしょう。そう!これを自在に使いこなすようになれれば、もしiOSが無くなっても、Macアプリ開発者としてやっていける(はず)です!

ということで、今回は、この SwiftUI の基本事項と Mac Catalyst での開発の導入までをまとめていきたいと思います。

動作環境

以下の環境で動作確認しました。

  • macOS Catalina(10.15.2 Beta)
  • xcode11.3 beta

※ ベータ版である理由は特にありません。ベータ版じゃなくても動作するはずです。

開発の準備

SwiftUIで開発を進めるために、まずは、xcodeの新規プロジェクトを作成します。
話を簡単にするために今回は、iOS > SinglePageApplicationを選択します。

ツリーの中から、ContentView.swift を選択します。このファイルがSwiftUIのエントリポイントになります。

Sampleアプリのお題

Sampleプログラムを組みながら、SwiftUIの基礎を把握していきたいと思います。
ドワンゴといえば、niconico 2 です。niconicoは映像の上にコメントが流れることが特徴的な動画/生放送サービスなので、サンプルプログラムもそれにちなんだものにしたいと思います。

今回は、以下のような動画の上にSwiftUIでコメントに見立てたテキストを並べて動かすというものを作ってみたいと思います。

GIFイメージ.gif

映像は、サンタが機械(MACHINE)に溶け込み、光速に飛び込んでいく様子を撮影したものです。
重ねて表示されるテキストは、お寿司です?
ハマチがちょっと痛んでしまっています。

SwiftUIの基礎を把握する

UI部分のサンプルコードは次の通りです。

ContentView.swift
import SwiftUI

struct ContentView: View {

    var body: some View {
        PlayerVCView()
        .overlay(GECommentView())
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

まず、SwiftUIの要点について、サンプルコードにも触れつつ、順に説明していきます。

SwiftUIではView構造は構造体(struct)で表現する

上記コードのメインとなるのは、ここです。

strict ContentView: View {
    //...
    var body: some View {
        // ここにViewの構造を定義する
        PlayerVCView()
        .overlay(GECommentView())
    }
    //...
}

ひと目見てわかるとおり、SwiftUIではViewの構造は、構造体(struct)で表現します。

そして、viewは定義するだけでよく、 addSubview() や removeFromSuperview() などの呼び出しは不要です。viewを組みたい構造に合わせて、structを入れ子の構造にしてあげるだけでレイアウトが簡単に組み上がってしまいます。とってもシンプル!

View#overlay() は、view同士を重ね合わせるメソッドです。
たったこれだけで、プレーヤーの上にコメントを載せることができてしまいます。

SwiftUIは、Viewから座標やsizeの取得ができない

なんと、SwiftUIでは、Viewのstructから直接、xy座標やsize(width,height)を取得することができません。
極力、座標指定せずにVStackなどのContainerやalignment、paddingなどを駆使して、座標やサイズ指定せずにレイアウトを組めるのが理想的な形だと思いますが、ときには、座標やサイズが必要なケースもあります。
そういうときのために用意されているのが、GeometryReaderです。(これについては、後述します。)

また、UIViewのように、viewのxy座標とsizeを指定して、配置するということもできません。唯一指定できるのは、offset(x,y) ですが、これは、そのviewの中心の座標をx:0,y:0 として指定します。
それと、viewを配置する際のデフォルトは、親Viewの中心(center)に合わせて配置されるようです。

宣言的なコーディング

SwiftUIでは、各viewのふるまいや見た目などについて、宣言的なコーディングを行います。前述の .overlay().animation() など、.hoge() の形式で記述していきます。最初はとっつきにくいかもしれませんが、慣れてくるときっと読みやすいコードが書けるようになるんじゃないかなと思います。

特徴的なview構造のルール

前述のコードの場合、登場するviewとその親子関係は次のようになります。

親;ルートのview(土台になっているview)
    子:ContentView(bodyのviewと同じsize)
        孫:PlayerVCView(プレーヤーのview)        
            ひ孫:GECommentView(コメントのview)

そして、SwiftUIでは、Viewの位置やsizeは、親ではなく子のViewが決めることになっているようです。

では、ここから PlayerVCViewGCCommentView の中を見ていきたいと思います。

UIViewControllerをSwiftUIから呼び出す方法

一般的な SwiftUI のチュートリアルでは、もっと後のほうに出てくる内容ですが、UIKitを触った事がある人だったら、これを先に見たほうが、 SwiftUI の理解が早まるかなと思うので、先に説明してしまいます。

PlayerVCView (自作struct)は動画を再生してくれるプレーヤーのviewです。iOSで動画再生する場合、 AVKitAVPlayerViewController を使うのが簡単です。シークバーなどのコントローラもよしなに提供してくれます。
もうちょっと複雑な制御をしたい場合、プレーヤーを作り込みたい場合は、 AVPlayer を利用します。

今回は、サンプルプログラムということで、AVPlayerViewController を使いたいと思います。コードは以下のとおりです。

PlayerVCView.swift
import SwiftUI
import UIKit
import AVKit

struct PlayerVCView: UIViewControllerRepresentable {
    typealias UIViewControllerType = AVPlayerViewController

    func makeUIViewController(context: UIViewControllerRepresentableContext<PlayerVCView>) -> AVPlayerViewController {
        let vc = AVPlayerViewController()

        let asset = NSDataAsset(name:"movie")
        let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("movie.mp4")
        try! asset!.data.write(to: url)
        let item = AVPlayerItem(url: url)
        vc.player = AVPlayer(playerItem: item)

        vc.player!.play() // auto play
        return vc
    }

    func updateUIViewController(_ uiViewController: AVPlayerViewController, context: UIViewControllerRepresentableContext<PlayerVCView>) {
        // NOP
    }
}

ViewControllerを SwiftUI から利用する場合、 UIViewControllerRepresentable protocol を実装します。
実装が必要なのは、typealias1つと、次の2つのメソッドです。

  • typealias UIViewControllerType
    • 対象のViewControllerクラスの型を指定します。今回の例では、 AVPlayerViewController になります。
  • makeUIViewController(context: Self.Context) -> Self.UIViewControllerType
    • ViewControllerでいうところの viewDidLoad にあたるメソッドです。ViewControllerの初期化などはここで行います。
  • updateUIViewController(Self.UIViewControllerType, context: Self.Context)
    • viewDidAppear 的なもの? ViewControllerの設定変更などの処理はここで行う。ちなみに、特別な処理がない場合は、空っぽでも問題ありません。

あとは、前述のように、このstructのイニシャライザを、表示したいところで呼び出すだけです。

    PlayerVCView()

UIViewをSwiftUIから呼び出す方法

UIViewを呼び出す方法も紹介しておきましょう。
AVPlayerを利用するケースだと、以下のようなコードになります。

PlayerView.swift
import SwiftUI
import UIKit
import AVKit

struct PlayerView: UIViewRepresentable {
    typealias UIViewType = PlayerUIView

    func makeUIView(context: UIViewRepresentableContext<PlayerView>) -> PlayerView.UIViewType {
        let player = AVPlayer(url: URL(string: "<<動画のURL>>")!)
        player.play()
        let playerView = PlayerUIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100), player: player)
        return playerView
    }

    func updateUIView(_ uiView: PlayerUIView, context: UIViewRepresentableContext<PlayerView>) {
        // NOP
    }
}

class PlayerUIView: UIView {
    private let playerLayer = AVPlayerLayer()

    init(frame: CGRect, player:AVPlayer) {
        super.init(frame: frame)
        self.playerLayer.player = player
        layer.addSublayer(playerLayer)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        playerLayer.frame = bounds
    }
}

AVPlayerはUIViewを直接持っていないので、別途UIViewのクラスを作って、AVPlayerのlayerをaddSublayerし、そのveiwを返すようにします。

UIViewRepresentable protocol を利用しますが、 UIViewControllerRepresentable とほとんどやることは変わりません。

レイアウトを決める

ここからは、 GECommentView の中を解説していきます。
プレーヤー上にコメントを表示し、アニメーションさせるviewです。
実装は次のとおりです。

GECommentView.swift
import SwiftUI

struct GECommentView: View {
    @State private var moveIt = false
    @State private var commentStrs = [
        "マグロ",
        "サバ",
        "ハマチ",
        "たまご"
    ]

    var animation: Animation {
        return Animation.default
    }

    var body: some View {

        let animation = Animation.easeInOut(duration: 1.0)

        return VStack {
            CommentLabelView(text: commentStrs[0], offset: moveIt ? 120 : -120, backgroundColor: .red) { view in
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                    self.moveIt.toggle()
                }
            }
            .animation(animation)

            CommentLabelView(text: commentStrs[1], offset: moveIt ? 120 : -120, backgroundColor: .blue) { view in

                       }
                .animation(animation.delay(0.1))

            CommentLabelView(text: commentStrs[2], offset: moveIt ? 120 : -120, backgroundColor: .green) { view in

                       }
            .animation(animation.delay(0.2))

            CommentLabelView(text: commentStrs[3], offset: moveIt ? 120 : -120, backgroundColor: .yellow) { view in

                       }
                .animation(animation.delay(0.3))

        }
        .onTapGesture { self.moveIt.toggle() }
    }
}

struct CommentLabelView: View {
    let text: String
    var offset: CGFloat
    let backgroundColor: Color
    var completion: (CommentLabelView) -> Void

    var body: some View {

        Text(text)
            .font(.headline)
            .padding(5)
            .background(BackgroundLabelView(cornerRedius: 5, color:backgroundColor))
            .foregroundColor(Color.black)
            .modifier(SlidingEffect(offset: offset, completion: { effect in
                self.completion(self)
            }))
    }
}

struct SlidingEffect: GeometryEffect {
    var offset: CGFloat
    let goalOffset: CGFloat
    var completion: (SlidingEffect) -> Void

    init(offset: CGFloat, completion: @escaping (SlidingEffect) -> Void) {
        self.offset = offset
        self.goalOffset = self.offset
        self.completion = completion
    }

    var animatableData: CGFloat {
        get { return CGFloat(offset) }
        set {
            offset = newValue
            if offset == self.goalOffset {
                self.completion(self)
            }
        }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: offset, ty: 0))
    }
}

struct BackgroundLabelView: View {
    var cornerRedius: CGFloat
    var color: Color

    var body: some View {
        GeometryReader { geometry in
            VStack(spacing:0) {
                RoundedRectangle(cornerRadius: self.cornerRedius).foregroundColor(self.color)
                    .frame(width: geometry.size.width, height: geometry.size.height / 2)
                RoundedRectangle(cornerRadius: self.cornerRedius).foregroundColor(Color.white)
                    .frame(width: geometry.size.width, height: geometry.size.height / 2)
            }
        }
    }
}

struct GECommentView_Previews: PreviewProvider {
    static var previews: some View {
        GECommentView()
    }
}

まず注目してほしいのが、bodyの中の、大枠の構造です。

    var body: some View {
      //...
      return VStack {
        //...
      }
      .onTapGesture { self.moveIt.toggle() }
    }

VStack というのは、V = vertical なstack、つまり垂直に要素を並べるためのstructです。このようなstructはいくつか種類があり、

  • HStack … 水平に要素を並べられる
  • ZStack … overlayで要素を並べられる
  • ScrollView … スクロールさせられる
  • Spacer … スペースを作り出す

などがあります。3

ジェスチャーを定義する

これも簡単です。
先ほどあった以下の部分は、

      .onTapGesture { self.moveIt.toggle() }

該当のviewをタップした際に、カッコ内の処理をさせるという事を示しています。
この例では、viewをタップする度に、moveItプロパティのbool値をtrue/falseとトグルで切り替えます。

アニメーションを定義する

アニメーションもジェスチャーのように簡単に定義できます。
先ほどのコードの以下の部分、

    let animation = Animation.easeInOut(duration: 1.0)

    CommentLabelView(...) { view in
    }
    .animation(animation.delay(0.1))

    CommentLabelView(...) { view in
    }
    .animation(animation.delay(0.2))

このように書くだけで、イージングでアニメーションできます。 4
よく見ると、下のCommentLabelViewには、delay をかけています。
こうする事で、同時にアニメーションを効かせたときに、ちょっとずつアニメーションをずらしていくことが可能です。

他にも、linear や、spring など、様々なアニメーションが用意されています。5

viewの状態を管理する

viewの状態管理には、いくつか種類がありますが、今回は、StateBinding について紹介します。

State - viewの内部でのみ状態を持たせたい場合

viewの内部でのみ状態を持ちたい場合は、 @State プロパティラッパーを使います。

    struct GECommentView: View {
      @State private var moveIt = false
      @State private var commentStrs = {
        //...
      }

    //...
    var body: some View {
      //...
        CommentLabelView(text: commentStrs[0], offset: moveIt ? 120 : -120, backgroundColor: .red) { view in
          //...
        }
    }

途中、省略しまくりなコードで若干わかりにくいですが、注目すべき箇所は、body内で moveIt がCommentLabelViewを生成する際の条件判定に利用されているということです。
このように、@State なプロパティをbody内で参照している場合、そのプロパティ値を変更するたびに、body内のviewが再計算されるという仕組みになっています。
これによって、Viewの状態に合わせてアニメーションさせたり表示を変更することが可能になります。

たとえば、以下のように書くと、

    var body: some View {
      if moveIt {
        HogeView()
      }
    }

moveItがtrueのときだけHogeViewを表示し、falseのときは非表示(viewが存在しない状態)という動きを作ることができます。

Binding - viewの外から状態を指定したい場合

viewの外から状態を指定したい場合は、 @Binding を使います。
今回のサンプルでは、Bindingを使っていないので書き方だけ紹介します。

    struct HogeView: View {
      @Binding var moveIt: Bool = false
      //...

    }

このように定義し、

    HogeView(moveIt: true)

外から状態を渡します。

GeometryReaderで座標やsizeを取得する

最初のほうで少し触れましたが、viewから座標やsizeを取得する方法がありません。
そういうときは、GeometryReaderを利用します。

    struct BackgroundLabelView: View {
      //...
      var body: some View {
        GeometryReader { geometry in
          VStack(spacing:0) {
            RoundedRectangle(cornerRedius: self.cornerRedius).foregroundColor(self.color).frame(width: geometry.size.width, height: geometry.size.height /2)
            RoundedRectangle(cornerRedius: self.cornerRedius).foregroundColor(Color.white).frame(width: geometry.size.width, height: geometry.size.height /2)
          }
      }
    }

このように、GeometryReader のクロージャの引数の geometry (GeometryProxy型) を使うと、sizeやwidth、heightなどが取得できます。
この場合、BackgroundLabelViewを貼り付けたCommentLabelViewのTextのサイズをとることができます。

GeometryEffectで複雑なアニメーションを実現する

シンプルなアニメーションの場合は、.animation()withAnimation { } で十分ですが、複雑なアニメーションを行う場合は、それだけでは難しいです。
そういうときは、GeometryEffect を使うとよいです。

struct CommentLabelView: View {
    //...

    var body: some View {

        Text(text)
            //...
            .modifier(SlidingEffect(offset: offset, completion: { effect in
                self.completion(self)
            }))
    }
}

struct SlidingEffect: GeometryEffect {
    var offset: CGFloat
    let goalOffset: CGFloat
    var completion: (SlidingEffect) -> Void

    init(offset: CGFloat, completion: @escaping (SlidingEffect) -> Void) {
        self.offset = offset
        self.goalOffset = self.offset
        self.completion = completion
    }

    var animatableData: CGFloat {
        get { return CGFloat(offset) }
        set {
            offset = newValue
            if offset == self.goalOffset {
                self.completion(self)
            }
        }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: offset, ty: 0))
    }
}

CGAffineTransform を使って、色々とviewの形状を変更することができそうですが、今回はシンプルにviewを左右に移動させることに利用しています。

サンプルコードで工夫したところは、アニメーションによってviewが左右の端まで来たことを親view(この場合、GECommentView)に通知するために、completionのクロージャを利用しているところです。

Macアプリとして実行する

ここまでSwiftUIの基礎を駆け足で紹介してきました。
最後にMacCatalystを利用して、macアプリとして動かしてみましょう。

実行は非常に簡単です。

プロジェクトファイル>General>Deployment Info>Device>「Mac」のチェックをつける

これだけです。 6 7

maccheckbox_screenshot

実行すると、このような表示になります。

iphone_screenshotmac_screenshot

おわりに

やや駆け足でしたが、SwiftUIの使い方の概要を紹介しました。
(Mac用に最適化する部分については全く触れられなかったので、もっと勉強していきたいと思います。)

viewを組み立てるときの概念がこれまでのUIKitとは異なっていて、最初はとっつきにいくい印象を受けますが、慣れればもっと簡単に、複雑なレイアウトをシンプルなコードで実現できるのではないかと思います。
viewまわりのコーディングをGUIを使わずに開発できるようになることは、今後のアプリ開発スタイルにも様々な影響を与えるのではないかなと思います。

今後は、そのあたりも模索してみたいと思っています。形になったらまた記事にしたいと思います。最後まで読んでいただき、ありがとうございました!

明日は、アドベントカレンダーも最終日です。
しかし… おかわり版である 第二のドワンゴ Advent Calendar 2019 の最終枠はまだ空いている模様。果たしていったい誰が書くのか!? いや、書かないのか!?乞うご期待です!

それではみなさま、素敵なクリスマスをお過ごしください?

お役立ちリンク

SwiftUI Tutorials
まずは一次情報を。チュートリアルがしっかり用意されているので学習しやすいです。
APIドキュメントはまだ説明が足りていない箇所も見受けられます。

https://developer.apple.com/documentation/swiftui
https://developer.apple.com/tutorials/swiftui/

Mac Catalyst
こちらも一次情報。
しっかりドキュメントがまとまっています(まだあまり読めていませんが…)

https://developer.apple.com/design/human-interface-guidelines/ios/overview/mac-catalyst/
https://developer.apple.com/documentation/uikit/mac_catalyst

The SwiftUI Lab
SwiftUIの細かいところまで具体的なサンプルコードで解説されていてすごく参考になります。

https://swiftui-lab.com
https://swiftui-lab.com/swiftui-animations-part2

Personal-Factory
WWDCのセッションや、SwiftUIの基礎について、詳しくわかりやすく書かれています。
最初に一読したほうがその後の学習が楽になると思います。

https://blog.personal-factory.com/2019/08/20/building-custom-views-with-swiftui/
https://blog.personal-factory.com/2019/12/08/how-to-know-coorginate-space-by-geometryreader/

AVPlayerをSwiftUIで利用する方法
AVPlayerをSwiftUIで利用する際の実装方法の参考になります。
https://medium.com/@chris.mash/avplayer-swiftui-b87af6d0553

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