20191002のiOSに関する記事は13件です。

Mapbox for iOSでサードパーティ製のスタイルを設定する

はじめに

地図系のフレームワークで有名で、最近ではゼンリンとの提携がニュースになったりしたMapboxはiOS向けにもフレームワークを提供しています。

Mapbox for iOSの導入

ただ地図を表示するだけなら、チュートリアルのとおりとてもシンプルに書けます。

import Mapbox

class ViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()

    let url = URL(string: "mapbox://styles/mapbox/streets-v11") //Mapbox公式スタイル
    let mapView = MGLMapView(frame: view.bounds, styleURL: url)
    mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    mapView.setCenter(CLLocationCoordinate2D(latitude: 59.31, longitude: 18.06), zoomLevel: 9, animated: false)
    view.addSubview(mapView)
  }
}

しかし上記コードのコメントにも書いたとおり、基本的には公式スタイルを設定するように作られています。
スタイルは、.json形式のデータにも関わらず、です。
公式スタイルを参照するのが最もラクですが、そのためにはAPIトークンが必要で、アクセス数に応じて料金が発生します。
ならば、OpenStreetMapなどオープンデータを活用し、公式スタイルを使わなければ良いのではないか?
という事で色々試しました。

サードパーティ製のスタイルを適用する

Mapboxのスタイルは、MGLMapViewの初期化時にstyleURLという引数で設定されます。
つまりURLを渡さなければなりませんが、どこかのサーバーにデータを置いておく、というのはナンセンスです。
という訳で、①好きなデータ等を設定したスタイルを.json形式で作成する、②①のローカルアドレスを取得しMGLMapViewに渡す、という方針になりました。

①:スタイルを.json形式で作成する

Swiftでの.jsonの取り扱い等は本記事では割愛します。
スタイルの.jsonファイル、言い換えると辞書形式のデータ構造は以下のとおりです。

    private var style:[String:Any] = [
        "version":8,
        "sources":[],
        "layers":[],
    ]

versionは8で固定です。sourcesはレイヤーデータの形式等の設定、layersはsourcesのデータをどのように表示するか(色など)の設定となっています。
つまりsourcesとlayersに適切な初期データを与えて、.jsonデータを作成すれば良いわけです。

    mutating func setBasemap(name:String, tileUrlStr:String, attributionUrl:String="", tileSize:Int=256) {
        let sources = [
            name:[
                "type":"raster",
                "tiles":[tileUrlStr],
                "attribution":"<a href='" + attributionUrl + "'>" + name + "</a>",
                "tileSize":tileSize
            ]
        ]

        let layers = [
            [
                "id":name,
                "type":"raster",
                "source":name,
                "minzoom":0,
                "maxzoom":18
            ]
        ]

        self.style["sources"] = sources
        self.style["layers"] = layers
    }

上記のコードは、とあるラスターレイヤーをスタイルに設定するサンプルです。
レイヤーのnameとタイルのtileUrlStrを与えてやればスタイルに追記します。
.jsonファイルの出力については、長くなるのでサンプルだけ貼ります。

    func writeJson(outputDir:String, filename:String) -> URL? {
        let nsHomeDir = NSHomeDirectory()
        let outputPath = nsHomeDir + outputDir + "/" + filename + ".json"

        do {
            let jsonData = try JSONSerialization.data(withJSONObject: self.style, options: .prettyPrinted)
            try jsonData.write(to: URL(fileURLWithPath: outputPath))
            return URL(fileURLWithPath: outputPath)
        } catch {
            print("error")
            return nil
        }
    }

②:①のローカルアドレスを取得しMGLMapViewに渡す

①のローカルアドレスは、writeJson()の返り値です。つまり以下のとおり書けます。

let tmpStyleUrl = msManager.writeJson(outputDir: "/tmp", filename: "style")
mapView = MGLMapView(frame: rect, styleURL: tmpStyleUrl!)

ここで、msManagerとはスタイル全般を取り扱うクラスです。
アプリを起動する度に、writeJson()でiOS内の/tmpにstyle.jsonとして出力しています。
/tmpは、アプリを終了した後のデータの保存は保証されない、一次保存用フォルダです。
今回のような用途にもってこいですね。

さて、これで純正スタイルを設定する必要がなくなりました。
API tokenを削除してみましたが、問題なく動作します。API tokenはMapboxスタイルにアクセスするためのものでした。

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

2週間でGAE+Datastore+MonacaによるiOS,Android,Webサービスをリリースするまでにやったこと

はじめに

Web初心者なので大変おこがましいと思いながらも、サービスをリリースするまでの過程について、備忘録も兼ねて記載していきたいと思います。 ポートフォリオも兼ねているため、今回収益化は度外視しているので、広告も出していません。次回以降は収益化も考慮して、より使ってもらえるサービスを作成していきたいと思います。

noteにもリリースするまでの話を記載しようと思っていますが、Qiitaではリリースするまでの技術的な内容を記載していこうと思っています。クラウドの発展やフレームワークの充実化が進み、便利な時代になっているもので、特にここではWebの初心者が2週間でサービスをリリースするまでやったことをフェーズ別に自分の経験を踏まえて備忘録として残しておきたいと思います。

先に使用したライブラリ・技術について記載をしておくと、

Webサイト

  • サーバー/PaaS(Google App Engine)
  • データベース(Datastore/Firestore)
  • CSSフレームワーク(Bootstrap)
  • サーバーサイド(Python/Flask(これは何でもよい!))

アプリ開発(Android/iOS)

  • Monaca

が今回の開発環境で、個人的には簡単なのでおすすめの組み合わせです。(Webサイトに関しては、Firebaseでも良いかもしれませんが。)

特に後述しますが、Monacaはモバイルアプリ開発の初学者に超おすすめです。 Cordovaベースのハイブリッドアプリ(iOSでもAndroidでもどこでも動くアプリ)作成ツールなのですが、クラウドIDEがあり、開発もビルドもクラウド環境で出来るため、ローカルでの環境構築が不要です。 つまり、Android StudioやXcodeを使ってiOSのSimulatorの環境を設定しなくても、クラウド上でポチポチするだけで簡単なデバッグや実機を用いた動作検証が可能です。(Cordovaベースなので少し遅いのがネックですが。)

対象読者

  • ワタシハフロントエンド(もしくはバックエンド)チョットダケデキルという人

逆にいうと、現役でソフトウェア開発に携わっている方にはありきたりな話になってしまうかもしれません!

サービス作成費用

サービス関連

  • ロゴ作成: $9 (約970円)
  • お名前.com(年間): 1円

その他の費用としては、

  • Chrome ウェブストア登録費用(初回): $5 (約540円)
  • Google プレイストア登録費用(初回): $25 (約2,700円)
  • Apple Store登録費用(年間): $99 (約10,700円)

Webサービスはほぼデザイン代だけで1,000円以下程度の費用で、App Store、Google Play Store、Chromeウェブストアの登録費用込みでも15,000円でお釣りが来る程度です。 更にその後開発したアプリやサービスは登録費用がかかりませんので、サービスを作ってみたい方は安い投資だと思って、登録してみるのも悪くないかと思います。

作成したサービス

3つの単語の組み合わせで、特定のURLにリダイレクトできるSantangoというサービスを作成しました。
what3wordsというイギリスのスタートアップのサービスのアイデアとbit.lyやGoogle URL Shorter(サービス終了済)のようなURL短縮サービスのアイデアを組み合わせて、3単語で任意のURLに飛ぶ事が出来るサービスです。

(web_top.png
▲作成したサービスの画面

技術的な面での機能としては以下の二点のみなので、簡単なサービスになっています。

  • Register URL
    • あるURLを登録して、特定の3単語を生成
  • Santango
    • URLから生成された3単語を用いて、元のURLにリダイレクト

具体的なユースケースとしては、セミナーやカンファレンスの登壇時に、スライドや資料をシェアする事を想定しています。通常だとQRコードか複雑なURLをシェアする必要がありますが、Santangoを介することで3単語だけシェアすればスライドや資料にアクセスすることが出来ます。 他にも、Qiitaのプライベート投稿のURLを口頭で伝える場面や大勢にまとめてシェアする場面などいくつかユースケースは考えられると思います。 特にエンジニアのように勉強会がある文化圏では便利な状況があると思うので、ぜひ使って頂けると幸いです。

ちなみにこの画面からリダイレクトしなくてもブラウザの検索バーに登録したり、Chrome Extensionやモバイル版(iOS/Andoroid)を使用することでより簡単にURLに飛ぶことが出来ます。

各フェーズについて

  • 企画フェーズ

    • 競合調査
    • スケッチ・ワイヤーフレーム・モックアップの作成
  • デザインフェーズ

    • CSSフレームワーク選定
    • ロゴ・テーマ作成
  • 開発フェーズ

    • 技術選定
    • Webサービス
    • バックエンド開発
      • Flask + GAE + Datastore
    • フロントエンド開発
      • CSS + JavaScript
    • ドメイン取得
    • Chrome Extension
      • JavaScript
    • Android/iOS
      • Monaca(Cordova-based)
  • 機能改善・運用フェーズ

    • パフォーマンスの高速化
    • アルゴリズムの改善
    • テスト
    • Google Analyticsの導入
  • その他

    • ドキュメンテーション
    • タスク管理
    • 宣伝
      • 広告動画作成

個人開発だとやる事が沢山あり、チームで進めるお気持ちが分かるようになります。実際の開発としては柔軟に別のフェーズに行き来しながら開発を進めていました。 ストア用マテリアルの作成やプライバシー規約など地道+面倒な作業のときは、Netflixを見ながら奇声を上げたり一人チルしたりゆるゆると進めていました。

企画フェーズ

競合調査

それでは早速プロダクトを作成していく...前に、簡単に競合調査を行いました。

  • bit.ly
    • URLの短縮サービス大手
    • 有償サービスあり
    • ランダム文字列ではない省略URLの提供
    • コンテンツのトラッキング
  • Google短縮サービス(goo.gl)
    • こちらも短縮サービス大手だけど、終了済。
    • 短縮サービスによるメリットが少なかった?
  • What3words
    • 住所を3単語で特定出来るサービスのBtoBビジネスモデル
    • ツバルやモンゴルの企業の一部では採用されている

今回作成したサービスは短縮サービスのデメリットの一つである、「URLが分かりづらい」という点を3単語で表現することで、「わかりやすい/覚えやすい」形でURLを表現するようにしました。ただし、ある程度短縮サービスについて詳しい方はお気づきかと思いますが、3単語で表現できるようになったからといって、URL短縮サービスの元々のデメリットの一つであるセキュリティ的な観点は今回のサービスでも同様に存在しますので、一つの懸念事項としてはありました。リダイレクト前にURL先を一時的に表示するなどして、今後の改善ポイントもあるかと思います。(他の短縮サービスではなぜやっていなかったのだろう。)

今回のサービスでは競合調査の深堀りをしていませんが、実際プロダクトを作成するにはどのようなビジネスモデルで収益を上げていくか、他サービスのポジションとの差別化を明確にしていくと良いかもしれません。

スケッチ・ワイヤーフレーム・モックアップの作成

次に実装する前のスケッチ・ワイヤーフレーム・モックアップに関してです。(ちなみに、それぞれの用語の細かい違いについてはこちらが参考になりそうです。)

まず、作成するサービスのイメージ図をiPadのGoodNotesというアプリで記述していきました。単純なメモ書きなので、紙でも良いと思います。

今回の場合は、

  1. スケッチ作成
  2. サーバーサイド実装(簡素な画面)
  3. モックアップ作成(BootstrapのThemeをダウンロードしてカスタム)
  4. 実装に乗せる

という流れで取り掛かりました。

ちなみに、今回はワイヤーフレームの作成をしなかったので使用しませんでしたが、もう少し複雑なアプリやサービスであれば、OverflowProttのようなサービスを使うと便利かもしれません。どちらもコード不要で、アプリやWebサービスの画面遷移図を記述して、ワイヤーフレームやモックアップを作成する事が出来るツールです。また、今回はスプラッシュ画面制作用にしか用いていませんが、Adobe XDでワイヤーフレームを作成していくことも可能です。

デザインフェーズ

CSSフレームワーク選定

こちらはだいぶ人の好みによって分かれるかと思いますが、私の場合は結局Bootstrapに落ち着きました。

開発着手当初は脱Bootstrapと意気込んで、別のフレームワークを使っていたのですが、細かい箇所でストレスも多く、ネットでも情報が一番多く実装が手軽だったので、Bootstrapにしました。テーマを変更する事でだいぶマシになるかと思いますし、資金に余裕がある方は4-5,000円なので有料版のテーマを購入しても良いかと思います。

ちなみに私が今回使用したテーマはこちらです。

ロゴ・テーマ作成

ロゴはLOGASTERというサイトを使いました。他にも良いサービスはいくつかあるかもしれませんが、$9 (約970円)なのでそれなりにお得な値段だと思います。こちらのデザインをベースにWebサイトのfavicon.ico、Chrome Exntesionの拡張機能のアイコン、iOS/Androidのアイコンを作成していきました。(私の環境はMacです)

Webサイト

  • favicon.ico(16x16)
    • 加工→プレビュー→ツール→サイズ変更→保存
  • トップページ
    • (加工なし)

Chrome Extensionの拡張機能

  • アイコン各所用
    • 16x16, 19x19, 32x32, 38x38, 48x48, 64x64, 128x128

iOS

  • スプラッシュ画面作成

    • Adobe XDでそれぞれのサイズを作成
    端末 サイズ
    iPhone 320x480
    iPhone Retina 640x960
    iPhone 5 640x1136
    iPhone 6 750x1334
    iPhone 6 Plus 1242x2208
    iPhone 6 Plus(Landscape) 2208x1242
    iPad 768x1024
    iPad(Landscape) 1024x768
    iPad Retina 1536x2048
    iPad Retina(Landscape) 2048x1536
  • アプリストア登録用アイコン

    • アルファチャンネルを削除しておく必要があるため、プレビュー→エクスポート→アルファチェックを外して保存

adobe.png

▲Adobe XDで作成したスプラッシュ画面

また、自分はデザインセンスなど皆無なのですが、UIに関してもサービス開発の上では重要だと考えています。CSS配色パターンはベストプラクティスがあるので、過去の賢人の知恵をお借りして配色を決定していきます。colorkittycolordropcolorhuntなど沢山配色パターンのサイトはありますが、私はcolorhuntを参考にして全体の配色を決めていきました。

また、サイト内の画像などに関しては、unsplashというフリーで商用可能な画像が多く集まっているサイトがあるので、こちらから画像を何点かお借りして少し編集を加えて載せています。特に今回のサービスでは緑などの自然系の色を用いて、開発を進めていきました。

開発フェーズ

技術選定

今回はポートフォリオということもあり、あまり深くは考えていませんでしたが、簡単かつなるべく費用はかけずにある程度はスケールできるような技術を選定していく必要がありました。なるべくミニマムなフレームワークで良いと思っていたので、サーバー側に関しては以下の技術を選びました。また、上述したように機能が簡素なものになっており、更にChrome Extensionもアプリ開発も実は中身は似たような内容になっているので、どちらも素のJavaScriptで記述しています。

サーバーサイド

  • Flask(PythonのミニマムなWebフレームワーク)
  • Google App Engine(GAE)
  • Datastore
  • CSS + Javascript

アプリ開発(Android/iOS)

  • Monaca(Cordova-based)

Chrome Extension

  • JavaScript

特にGAE、Datastore、Monacaがめちゃ便利です。

Webサービス

バックエンド開発

  • Flask
  • GAE
  • DataStore

を用いて作成を行いました。 詳しい実装方法は長くなりそうなので避けますが、大まかには以下のような流れで進めました。

  1. flaskでゴニョゴニョ開発
  2. ローカル環境での動作が確認できたら、gcloudを使って開発環境にデプロイ
  3. 開発環境での動作が確認できたら、gcloudを使って本番環境にデプロイ

個人的にGAEが便利だと思っている事の一つですが、バージョン名を変更する事で、簡易的に開発環境と本番環境を切り分けて動作を確認する事が出来ます。本番時に何か障害が発生した場合には、手軽に以前のバージョンに戻す事が可能です。また、アクティブなトラフィックを分割する事も可能なので、ABテストも簡単に行う事ができます。GAE、DataStoreのメリットとしては、そこそこスケールが出来て、ある程度のトラフィックまではほぼ無料で使用する事がかなり大きいです。例えば、Datastoreでは1日に書き込み20,000件、読み込み50,000件呼ばれても無料枠に収まりますし、それ以上アクセスが来ても個人レベルだとそこまで費用は掛かりません。便利。

フロントエンド開発

そこまで複雑になる予定がなかったため、フロントエンド箇所はバックエンドより更にシンプルにコーディングしています。素のJavaScriptでのみ記載しており、オールインワンでスクリプトにて記述しているため、かなり乱雑です。

ここではChrome Developer Toolsを用いて、CSS / JavaScriptを記述していきます。そこまで複雑な処理を記載していませんが、以下のような実装、確認を行っています。

  • CSSデザインの確認
    • 特にレスポンシブが動作しているかどうか
    • 様々な機種でWebサイトを確認したときに、ずれないようになっているか
  • Javascriptの内容の確認
    • ここでは若干のアニメーションの動作確認

今回のようなシンプルな機能のサービスだとそこまで記述する内容が少なかったですが、実際はフレームワークを使用する事が多いです。

ドメイン取得

こちらもGAEの良い点ですが、

  • SSLの証明書が自動で発行してくれるマネージドSSLがある(さらにSSLエンドポイントが世界中にあり、負荷分散してくれる)
  • カスタムドメインを登録するだけで簡単に使用できる

とメリットが多く簡単なので、初学者でも簡単に作成することが出来ます。

ちなみに自分はお名前.comでドメインを取得しましたが、ドメイン取得サイトはどこでも良いと思います。Google Domainだとより手軽に連携できるようです。

取得したドメインをこちらの手順に従って登録していけば完了です。

Chrome Extension

今回のアプリでは特にChrome Extensionが便利になるかと思い作成しました。Chromeの拡張機能は作成方法や公式サンプルも豊富だし初回の登録費用もたったの$5なので、アプリ開発よりかなり敷居が低く、初心者でも参入しやすいと思います。また、個人的に感じたのは、App StoreやPlay Storeと違い、Chromeのウェブストアではマテリアルが簡素な事が多かったです。

ちなみに、Chrome Extensionの中で使用しているXMLHttpRequestという仕組みをiOS/Andoroidでも同じように使用しています。

Chrome拡張機能の作成方法は

  1. manifest.jsonの記述
  2. instructionの追加・記述(ここではbackground.js)
  3. ユーザーインターフェースの追加(ここではpopup.html)
  4. アイコン・マテリアルをせこせこ作成
  5. Chromeウェブストアに登録(更新)

という流れになっています。

popup.png
▲ユーザーインターフェース(popup.html)

webstore.png
▲下部分の画像が作成したマテリアル

ちなみにマテリアルの作成には、Powerpointを使用しました。かなりの少数派だと思いますが、Powerpointを使用したことがある方は意外と簡単に作成出来るかと思います。ちなみに今回のChromeウェブストア・Googleプレイストア・App Storeそれぞれのストア用画像を作成しました。

Android/iOS

アプリ開発ではMonacaと呼ばれるハイブリッドアプリ開発プラットフォームを使用しました。一度作成してしまえば、Android/iOSでも似たような挙動で動作することが可能です。他のハイブリッドアプリ開発手法やネイティブアプリに比べると挙動は遅いという点はありますが、初学者でも簡単にアプリを作成する事が出来ます。また、React、Vue、Angularなども対応をしているため、順当に学んでいけば、より凝ったアプリを作成することも可能です。(ただし、MonacaやOnsenUI特有の挙動もありますが)

Monacaの主なメリットとしては以下のようになります。

  • Android/iOSそれぞれの開発環境を整備しなくても、クラウド上で開発・ビルドが可能
  • 実機でのデバッグが簡単に可能
  • 1日のビルド上限数があるが、無料プランでもある程度使える

機能改善・運用フェーズ

パフォーマンスの高速化

この時点でそこまで気にする必要はないのですが、PageSpeed InsightsというGoogle製のパフォーマンス測定を行ってくれるツールがあります. こちらでパフォーマンス測定を行ってみましょう。

speed.png
▲Pageinsightのスコアの画面

このようにある程度のスコアが出ると、アクセスの速度としては問題ないかと思います。

そして、パフォーマンス測定後のアクションとして例えば以下のようなものがあります

  • 使用していないCSS/JSの削除
  • 大きい画像を圧縮

使用していないCSS/JSの削除に関しては、UnusedCSSPurifyCSSという使用していないCSS箇所を削除してくれるサービスがあります。私が今回使用したのはPurfiyCSSをオンラインで実行してくれるPurifyCSS Onlineというサービスを使用しました。

purify.png
▲PurifyCSS Onlineの画面

今回のプロダクトではCSSのボリュームがそこまで大きくなかったのですが、もっと大きいプロジェクトの場合は変化が出てくるかと思います。

また、よしなに画像を圧縮してくれるGoogleのSquooshというサービスでは、ブラウザ上で画像をドラッグアンドドロップ+簡単な操作で、軽い画像を生成してくれるので、元画像と置き換えておくと便利です。

squoosh.png
▲Squoosh

特に画像の場合はCSSより大きいので、先程行った無駄なCSSの削除より画像の圧縮の方が効果が大きい事が多いです。

また、一つ気をつけないといけない点として、GAEのスピンアップ時のパフォーマンス測定と通常時のパフォーマンス測定を切り分けて考慮する点があります。

GAEは基本的に安く運用するために常時インスタンスの起動をしておらず、一定時間アクセスがないとインスタンスをシャットダウンします。これをスピンダウンと言います。 これに対して、アクセスがあった際に、インスタンスを立ち上げてリクエストの処理を行います。これをスピンアップと言います。

そのため、パフォーマンス測定時にスピンアップの問題なのか、サーバーそのものの問題なのか切り分けて効果を測定する事が必要です。

アルゴリズムの改善

今回作成したサービスでは「いかに覚えやすいか」が肝にもなっているので、アルゴリズムの工夫やデータの前処理に関しての工夫は重要になっています。詳細については割愛しますが、以下のような流れで作成していました。

  1. データセットの取得
  2. 前処理
  3. 覚えやすい単語の組み合わせ生成

使用しているデータは単語の羅列データなので、今回は簡易的に品詞を付与して、文法を入れたルールベースによる単語の組み合わせ生成を行っています。 発展的には機械学習を応用することで「覚えやすい単語の組み合わせの生成」などを行う事が出来ます。しかし、URLの登録の際、単語の組み合わせを生成するリアルタイム処理が必要なため、URL生成のバッチ処理+プリフェッチ処理を加えるなどの工夫をする必要があるため、少しコストが高いです。そのため、重要な箇所ですが、現状は簡易的なアルゴリズムをリアルタイムで動作するようにしています。

テスト

基本的にはテストを書きつつ実装を進めていった方が良いし、いつでもテストを書く癖をつける事は重要だと思っていますが、プロトタイピングの中ではテストコードをガッツリ書いたり、CIの環境を整備するよりも、他に優先すべき点があるかと思っています。個人的には抑えるべきテストをチェック出来ていれば良いかと思っています。ここらへんは経験の浅さから認識が甘い点があるかもしれませんので、あまり参考にはならないかもしれません。

そのため、今回のプロダクトではテストに関してそこまで行わず、以下の確認を行っています。

  • 単体テストの記述
  • 最低限チェックしたいテストケースのドキュメンテーションと実行
    • 自動化せずに、開発環境デプロイ後に手動でチェック

testcase.png
▲テストケースのイメージ

Google Analyticsの導入

基本的に導入しておくと便利なので早めに入れておいて良かったです。細かく目標やコンバージョンの設定なども出来るのですが、単純なアクセス解析程度の用途でも個人プロダクトだと十分活用することが出来ます。

例えば、

  • ユーザーの国別情報(どこの国からアクセスがあるか)
  • デバイス情報(スマホかタブレットかPCか)
  • どこのページから飛んできたか
  • どの時間帯にアクセスが来ているか

といった基本的な情報は設定しなくても取得することが出来ます。

その他フェーズ

ドキュメンテーション

自分の性格的に得意ではないのですが、GAEのバージョン管理のログ、参考になったURL、テストケース、やったことの概要などに関して、ドキュメンテーションを残しておく事は重要で、後々見返してきたときに便利です。

私は開発やコンペなどの個人的なプロジェクトはNotionというツールを使っています。ちなみにNotionの容量は限界がないので、動画などをドキュメンテーションの一部に入れておいても全く問題ないので、便利です。

project.png

▲Notionのプロジェクトページ内の目次一覧

タスク管理

開発プロダクトやコンペごとにTrelloのボードを切って、一人プロジェクトを進めていました。 全プロジェクト横断で確認したいときがあるので、他のツールやプラグインなど知見がある方は教えて頂けると助かります。

trello.png
▲Trelloボード

宣伝

今回は特に行っていませんが、通常であればこの後広告を打ったり、SEO対策としてコンテンツを増していったり、とやり方は色々ありそうです。 私はあまり詳しくありませんが、Google Search Consoleなどを使ったSEO施策を行っていく事で、広告よりもコスパ良くコンバージョンにつなげていく事が多いそうです。

また、開発会議SafariServiceといったサービスを紹介していただけるサイトも豊富なので、登録してみても損はないと思います。 このあたりのTODOに関しては、こちらのブログのリリース後にやったことリストが参考になりました。

また、私の場合は英語のサービスを作成していたので、Y Combinatorが支援をしているProduct Huntにも登録をしてみました。もしサービスを気に入っていただけた方はupvoteしていただけると幸いです!

動画作成

また、結局非公開にしましたが、Youtube動画を作成して、トップページに配置するのも一つの手かと思います。(どちらかというとデザインに近い話ですが) Youtubeに宣伝動画があると、Webのトップページ、Chromeウェブストア、Google Playストア、App Storeに乗せる事が出来ます。

勿論動画作成ツールなどを使用するのも良いと思いますが、個人開発レベルであれば、以下のサービスを組み合わせて作成するのが簡単かと思います。

  • サービスのスクリーンショット
    • Quick Time Player
  • 動画編集
    • iMovie
    • Youtube動画エディタ
  • アニメーション機能
    • Powerpoint

まとめ

サービス開発従事者の方(特にエンジニア)にはありきたりの内容が多かったかと思いますが、自分の中での整理も兼ねて個人サービス開発の工程を書いてみました。初学者なので、何か追加した方が良い事柄や、より良いツールなどアドバイス頂けると幸いです!

本記事ではフェーズごとに行ったことをまとめてみました。個人開発の備忘録としては、便利なサービスが豊富な時代なので、思っているより簡単に個人開発を行う事が出来ます。登録費用を別にしたら、費用としても1,000円ぐらいなので、個人プロダクト開発に手を出してみたいと思っている方はぜひ挑戦してみてください。

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

Xcode11で作成したプロジェクトでは`[UIWindow new]`で生成したWindowが画面に表示されない問題の対処法

Xcode11とUISceneとUIWindowにまつわるトラブルシュートに追われている。
個別のケースに分解してお届け。

現象

  • Xcode11で作成したプロジェクトをiOS13で実行しているとき、UIWindowが表示されない
  • rootViewController持たせて、viewDidLoadにbreak pointを貼ったりすると、動作はしていることが確認できる。

原因

  • Xcode11で作る新規プロジェクトは、Scene Based Lifecycleが有効になっている。
  • Scene Based Lifecycleが有効になっていると、[UIWindow new], [[UIWindow alloc] initWithFrame:] で生成したwindowは View Hierarchyに載らなくなる
    • UIWindowを載せるべきWindowSceneが不明なため

対応

ひとつは、Xcode11で作成したプロジェクトからSceneに関する機能をオミットする方法。

もうひとつは正攻法。(本記事)

[[UIWindow alloc] initWithWindowScene:]で windowを生成する。

そのためには、なんらかの手段によってUIWindowSceneを拾い上げる必要がある。

ボタンを押した時に表示したいとか、特定のViewと同じUIWindowSceneを選択する場合

ViewController.m
@interface ViewController ()
@property (nonatomic) UIWindow *window;
@end

@implementation ViewController
- (IBAction)openWindow:(UIButton *)sender {

    UIWindowScene *scene = sender.window.windowScene;
    UIWindow *window = [[UIWindow alloc] initWithWindowScene: scene];

    // 以下は確認用の見た目調整と表示
    UIViewController *viewController = [UIViewController new];
    viewController.view.backgroundColor = UIColer.greenColer;
    window.frame = CGRectMake(20, 20, 50, 50);
    window.rootViewContoller = viewController;

    self.window = window;
    [self.window makeKeyAndVisible];
}
@end
ViewController.swift
class ViewController: UIViewController {
    var window: UIWindow?

    @IBAction func openWindow(_ sender: UIButton) {

        guard let scene = sender.window?.windowScene { return }
        let window = UIWindow(windowScene: scene)

        // 以下は確認用の見た目調整と表示
        let viewController = UIViewController()
        viewController.view.backgroundColer = .green
        window.frame = CGRect(x:20, y:20, width:50, height:50)
        window.rootViewContoller = viewController

        self.window = window
        self.window?.makeKeyAndVisible();
    }
}

Sceneは一つだけのアプリである場合など、UIApplicationからUIWindowSceneを引っ張りたい場合

ViewController.m
@interface ViewController ()
@property (nonatomic) UIWindow *window;
@end

@implementation ViewController
- (IBAction)openWindow:(UIButton *)sender {

    UIWindowScene *scene = (UIWindowScene*)[[[[UIApplication sharedApplication] connectedScenes] allObjects] first];
    UIWindow *window = [[UIWindow alloc] initWithWindowScene: scene];

    // 以下は確認用の見た目調整と表示
    UIViewController *viewController = [UIViewController new];
    viewController.view.backgroundColor = UIColer.greenColer;
    window.frame = CGRectMake(20, 20, 50, 50);
    window.rootViewContoller = viewController;

    self.window = window;
    [self.window makeKeyAndVisible];
}
@end
ViewController.swift
class ViewController: UIViewController {
    var window: UIWindow?

    @IBAction func openWindow(_ sender: UIButton) {

        guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { return }
        let window = UIWindow(windowScene: scene)

        // 以下は確認用の見た目調整と表示
        let viewController = UIViewController()
        viewController.view.backgroundColer = .green
        window.frame = CGRect(x:20, y:20, width:50, height:50)
        window.rootViewContoller = viewController

        self.window = window
        self.window?.makeKeyAndVisible();
    }
}

参考URL

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

Xcode11で作成したプロジェクトでは`[UIWindow new]`で生成したWindowが画面に表示されない

Xcode11とUISceneとUIWindowにまつわるトラブルシュートに追われている。
個別のケースに分解してお届け。

現象

  • Xcode11で作成したプロジェクトをiOS13で実行しているとき、UIWindowが表示されない
  • rootViewController持たせて、viewDidLoadにbreak pointを貼ったりすると、動作はしていることが確認できる。

原因

  • Xcode11で作る新規プロジェクトは、Scene Based Lifecycleが有効になっている。
  • Scene Based Lifecycleが有効になっていると、[UIWindow new], [[UIWindow alloc] initWithFrame:] で生成したwindowは View Hierarchyに載らなくなる
    • UIWindowを載せるべきWindowSceneが不明なため

対応

ひとつは、Xcode11で作成したプロジェクトからSceneに関する機能をオミットする方法。

もうひとつは正攻法。(本記事)

[[UIWindow alloc] initWithWindowScene:]で windowを生成する。

そのためには、なんらかの手段によってUIWindowSceneを拾い上げる必要がある。

ボタンを押した時に表示したいとか、特定のViewと同じUIWindowSceneを選択する場合

ViewController.m
@interface ViewController ()
@property (nonatomic) UIWindow *window;
@end

@implementation ViewController
- (IBAction)openWindow:(UIButton *)sender {

    UIWindowScene *scene = sender.window.windowScene;
    UIWindow *window = [[UIWindow alloc] initWithWindowScene: scene];

    // 以下は確認用の見た目調整と表示
    UIViewController *viewController = [UIViewController new];
    viewController.view.backgroundColor = UIColer.greenColer;
    window.frame = CGRectMake(20, 20, 50, 50);
    window.rootViewContoller = viewController;

    self.window = window;
    [self.window makeKeyAndVisible];
}
@end
ViewController.swift
class ViewController: UIViewController {
    var window: UIWindow?

    @IBAction func openWindow(_ sender: UIButton) {

        guard let scene = sender.window?.windowScene { return }
        let window = UIWindow(windowScene: scene)

        // 以下は確認用の見た目調整と表示
        let viewController = UIViewController()
        viewController.view.backgroundColer = .green
        window.frame = CGRect(x:20, y:20, width:50, height:50)
        window.rootViewContoller = viewController

        self.window = window
        self.window?.makeKeyAndVisible();
    }
}

Sceneは一つだけのアプリである場合など、UIApplicationからUIWindowSceneを引っ張りたい場合

ViewController.m
@interface ViewController ()
@property (nonatomic) UIWindow *window;
@end

@implementation ViewController
- (IBAction)openWindow:(UIButton *)sender {

    UIWindowScene *scene = (UIWindowScene*)[[[[UIApplication sharedApplication] connectedScenes] allObjects] first];
    UIWindow *window = [[UIWindow alloc] initWithWindowScene: scene];

    // 以下は確認用の見た目調整と表示
    UIViewController *viewController = [UIViewController new];
    viewController.view.backgroundColor = UIColer.greenColer;
    window.frame = CGRectMake(20, 20, 50, 50);
    window.rootViewContoller = viewController;

    self.window = window;
    [self.window makeKeyAndVisible];
}
@end
ViewController.swift
class ViewController: UIViewController {
    var window: UIWindow?

    @IBAction func openWindow(_ sender: UIButton) {

        guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { return }
        let window = UIWindow(windowScene: scene)

        // 以下は確認用の見た目調整と表示
        let viewController = UIViewController()
        viewController.view.backgroundColer = .green
        window.frame = CGRect(x:20, y:20, width:50, height:50)
        window.rootViewContoller = viewController

        self.window = window
        self.window?.makeKeyAndVisible();
    }
}

参考URL

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

Cordovaで音声入力する。

UIWebViewではnavigator.mediaDevicesが取得できず、audio inputが出来ない。
cordova-plugin-audioinputを使う。

導入

cordova plugin add cordova-plugin-audioinput@1.0.1

サンプルコードが現時点で最新の1.0.2では動かなかったため、1.0.1を入れた。

Blob形式でデータを取得する流れ

  • 公式デモソースが教えてくれる。

https://github.com/edimuj/cordova-plugin-audioinput/blob/master/demo/wav-demo.js

Permissionを取る

準備

document.getElementById("startCapture").addEventListener("click", startCapture);

// 送られてくるデータを貯める
var onDeviceReady = function () {
    if (window.cordova && window.audioinput) {
        initUIEvents();

        consoleMessage("Use 'Start Capture' to begin...");

        // Subscribe to audioinput events
        //
        window.addEventListener('audioinput', onAudioInputCapture, false);
        window.addEventListener('audioinputerror', onAudioInputError, false);
    }
    else {
        consoleMessage("cordova-plugin-audioinput not found!");
        disableAllButtons();
    }
};

onAudioInput内部で、音声ストリーミングを取得する

開始

audioinput.start({audioSourceType: 0})

終了

//停止
audioinput.stop();

// waveへ変換する    
consoleMessage("Encoding WAV...");
var encoder = new WavAudioEncoder(captureCfg.sampleRate, captureCfg.channels);
encoder.encode([audioDataBuffer]);

consoleMessage("Encoding WAV finished");

var blob = encoder.finish("audio/wav");
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【iOS】タブページャーOSSのParchmentでSelf-Sizingを行う

iOSのタブページャーOSSはXLPagerTabStripというライブラリが有名ですが、カスタマイズが効きにくくメンテナンスが最近されていていないので、Parchmentというライブラリを使ってみました。

ただ、普通にやるとSelf-Sizingに対応していないので、いくつか工夫をして解決しました。

案件で使っているコードを雰囲気コードとして抽象化して取り出したので、基本的にはアイデアの参考になるコードとして見て貰えると嬉しいですが、動かなかったら教えて下さい。

要件

テキストと画像の混じったタブアイテムをセルフサイジングさせたい。

コード

Self-SizingサイズはPagingViewControllerDelegateを設定することで実現可能です

ViewDidLoad.swift
let pagingViewController = PagingViewController<TabItem>()
pagingViewController.delegate = self

delegateのpagingItemがHashableで帰ってくる関係上、hashValue でアイテムを引っ掛けてサイズを返すしか方法が思いつかなかったので、そのアイデアで実現しました。

enum TabItem: Int, PagingItem, Hashable, Comparable, CaseIterable {
    case first
    case second
    case third
    case fourth
    case fifth

    enum TabType {
        case label(String)
        case icon(UIImage)
    }

    var tabType: TabType {
        switch self {
        case .first:
            return .icon(UIIImage(named: "yourImage")!)
        case .second:
            return .label("second")
        case .third:
            return .label("third")
        case .forth:
            return .label("forth")
        case .fifth:
            return .label("fifth")
        }
    }

    static func < (lhs: NewsTabItem, rhs: NewsTabItem) -> Bool {
        return lhs.rawValue < rhs.rawValue
    }

}

private extension TabItem {

    private var sideMargin: CGFloat { return 15 }

    func estimatedWidth(font: UIFont) -> CGFloat? {
        switch tabType {
            case .label:
                let textWidth = (text as NSString).size(withAttributes: [.font: font]).width
                return sideMargin + textWidth + sideMargin
            case .icon:
                let imageWidth: CGFloat = 15
                return sideMargine + imageWidth + sideMargine 
        }

    }

}

extension MyPagingViewController: PagingViewControllerDelegate {

    func pagingViewController<T>(_ pagingViewController: PagingViewController<T>, widthForPagingItem pagingItem: T, isSelected: Bool) -> CGFloat? where T: PagingItem, T: Comparable, T: Hashable {
        guard let tabItem = TabItem.allCases.first(where: { $0.hashValue == pagingItem.hashValue }) else {
            return nil
        }
        return tabItem.estimatedWidth(font: .descriptionBold)
    }
}

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

MVVM用のXcodeのテンプレート作ってみた

最近RxSwiftを使ったMVVMを勉強してるのですが毎回ViewController作って、ViewModel作って、連結部分書いてってやるの流石に面倒になってきたのでXcodeのテンプレート作ることにしました。

既存のテンプテートはここにあるみたいです。
/Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/
1から作るのはめんどくさそうなので一番シンプルそうなSwiftファイルのテンプレートをコピーしていじっていこうと思います。

Customというディレクトリ名で作ってみます。

sudo mkdir /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/Custom/

そして既存のSwiftファイルのテンプレートをコピーしてくる、ついでMVVM.xctemplateにリネーム

sudo cp -r /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/Source/Swift\ File.xctemplate /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/Custom/MVVM.xctemplate

確認してみます。

open /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/

こんな感じになってたらOKです。
スクリーンショット 2019-10-02 12.27.55.png

___FILEBASENAME___.swiftを編集していきます。
一回の操作でViewControllerとViewModelを作りたかったので自分はこんな感じにしました。
スクリーンショット 2019-10-02 11.41.53.png

___VARIABLE_NAME___ViewController.swift
//___FILEHEADER___

import UIKit
import RxSwift
import RxCocoa

class ___VARIABLE_NAME___ViewController: UIViewController {

    private var disposeBag = DisposeBag()
    private let viewModel = ___VARIABLE_NAME___ViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()
        configure()
    }

    private func configure() {
        //outputs

        //inputs

        viewModel.inputs.configure()
    }
}

___VARIABLE_NAME___ViewModel.swift
//___FILEHEADER___

import Foundation
import RxSwift
import RxCocoa

protocol ___VARIABLE_NAME___ViewModelInputs {
    func configure()
}

protocol ___VARIABLE_NAME___ViewModelOutputs {
}

protocol ___VARIABLE_NAME___ViewModelType {
    var inputs: ___VARIABLE_NAME___ViewModelInputs { get }
    var outputs: ___VARIABLE_NAME___ViewModelOutputs { get }
}

final class ___VARIABLE_NAME___ViewModel: ___VARIABLE_NAME___ViewModelType, ___VARIABLE_NAME___ViewModelInputs, ___VARIABLE_NAME___ViewModelOutputs {

    //Properties
    private var disposeBag = DisposeBag()
    var inputs: ___VARIABLE_NAME___ViewModelInputs { return self }
    var outputs: ___VARIABLE_NAME___ViewModelOutputs { return self }

    //Functions
    func configure() {
    }
}
TemplateInfo.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Kind</key>
    <string>Xcode.IDEFoundation.TextSubstitutionFileTemplateKind</string>
    <key>Description</key>
    <string>An empty Swift file.</string>
    <key>Summary</key>
    <string>An empty Swift file</string>
    <key>SortOrder</key>
    <string>30</string>
    <key>AllowedTypes</key>
    <array>
        <string>public.swift-source</string>
    </array>
    <key>DefaultCompletionName</key>
    <string>File</string>
    <key>MainTemplateFile</key>
    <string>___FILEBASENAME___.swift</string>
    <!-- 追加ここから -->
    <key>Options</key>
    <array>
        <dict>
            <key>Identifier</key>
            <string>NAME</string>
            <key>Required</key>
            <true/>
            <key>Name</key>
            <string>名称を入力してください。 Name:</string>
            <key>Description</key>
            <string>「Hoge」と入力するとHogeViewController,HogeViewModelが生成されます。</string>
            <key>Type</key>
            <string>text</string>
            <key>Default</key>
            <string>Hoge</string>
        </dict>
    </array>
    <!-- 追加ここまで -->
</dict>
</plist>

変数の利用方法などこちらのサイト様を参考にさせていただきました。

設定は以上になります。確認してみましょう。
New File...からの
スクリーンショット 2019-10-02 12.01.49.png
スクリーンショット 2019-10-02 12.02.06.png
↓↓↓ここで決めたファイル名は反映されないので適当にEnter押しちゃってください
スクリーンショット_2019_10_02_12_06.png
スクリーンショット 2019-10-02 12.02.37.png
スクリーンショット 2019-10-02 12.02.50.png

良い感じに出来てますね!
気がかりはファイル名を決める画面が全く機能してないところですね、できればスキップしたい、、、
なにか回避策があればコメントいただけると嬉しいです。

今回のコードは↓↓↓にアップしてます。
https://github.com/akasasan454/Xcode_template

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

【Swift】WKWebViewでピンチによるズームを無効にする

import UIKit
import WebKit

class WebViewController: UIViewController {

    @IBOutlet weak var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()
        guard let url: URL = Bundle.main.url(forResource: "index", withExtension: "html") else { return }
        webView.scrollView.delegate = self
        webView.loadFileURL(url, allowingReadAccessTo: url)
//        webView.scrollView.pinchGestureRecognizer?.isEnabled = false
//        ↑↑↑この時点で書いてもpinchGestureRecognizerがnilなので意味ない
    }
}

extension WebViewController: UIScrollViewDelegate {
    func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
        scrollView.pinchGestureRecognizer?.isEnabled = false
//      ↑↑↑delegateの中で書いてやると良い
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ダークモード対応で今注意すべきこと

この記事は potatotips #65 で発表した内容を一部テキスト化したものです。

TL;DR

iOS 12 以下をサポートしつつ、ダークモード対応するなら、Mac をライトモードにしたほうが良いかもしれません。
ダークモードの Mac で xib/Storyboard を編集すると Asset Catalog で作成した色の RGB がダークモードの色になり、iOS 12 以下でダークモードの色が表示されるケースがあります。

xib/Storyboard に Asset Catalog で作成した色を設定した後で、その色を編集しても色の変更が View に反映されない可能性があります。 (iOS 12 以下のみ)

動作確認環境

  • macOS Mojave 10.14.6
  • Xcode 11.0 (11A420a)

この内容は環境に依存している可能性が高いため、記載しておきます。
もし、Catalinaなら直ってるよ、などの知見がある方はコメントいただけると嬉しいです!

iOS 12 以下でダークモードの色が表示されてしまう

iOS 13 で動作確認をしながらダークモード対応を進め、ある程度進んだ段階で iOS 12 の端末で確認をすると、何故か一部の色がダークモードで表示されていました :scream:

色々検証していく中で以下の問題が分かりました。

  • Mac をダークモードにしていると、xib/Storyboard に埋め込まれるカスタム色 1 の RGB がダークモードの色になる
    • iOS 13 では外観モードに合わせた正しい色が表示されるが、
iOS 12 以下では埋め込まれた色が表示されてしまうケースがある
  • xib/Storyboard にカスタム色を設定した後、
Asset Catalog でその色を編集しても View 側の色が変わらない (iOS 12 以下のみ)

Mac をダークモードにしていると、xib/Storyboard に埋め込まれるカスタム色の RGB がダークモードの色になる

このような色を作成します。名前は DynamicColor とします。ライトモードの時は赤、ダークモードの時は青が表示されるはずです。
image.png

この色を Mac がライトモードの時に xib/Storyboard 内の View に設定すると以下のような色が埋め込まれます。

<resources>
    <namedColor name="DynamicColor">
        <color red="1" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
    </namedColor>
</resources>

一方、Mac がダークモードの時に設定すると以下のような色になります。

<resources>
    <namedColor name="DynamicColor">
        <color red="0.0" green="0.0" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
    </namedColor>
</resources>

本来関係ないはずの Mac 本体の外観設定が iOS のリソースに反映されてしまっています... :innocent:
これだけでもチーム内でライト派/ダーク派が分かれている場合、Git の差分を見るのが辛くなります...

続いて、以下のような View を用意します。単に IBInspectable で背景色を設定できる View です。 (検証用なので無駄なコードです)

class View: UIView {
    @IBInspectable var ibBackgroundColor: UIColor = .white {
        didSet {
            backgroundColor = ibBackgroundColor
        }
    }
}

そして、Mac をダークモードにした状態で、以下のように色を指定します。

↓デフォルトで用意されている Background で DynamicColor を設定
image.png

↓IBInspectable で増えた項目を利用して DynamicColor を設定
image.png

すると、iOS 12 で以下の表示になります。

なぜか、IBInspectable で設定した背景色が iOS 12 なのにダークモードになっています!!! :disappointed_relieved:

ちなみにこれを iOS 13 で確認すると以下のように正しい表示になります...

image.png

対策

全パターンを把握できていませんが、IBInspectable (User Defined Runtime Attributes) で
設定した色はこの現象が起きやすいです。

この問題を回避するためには現状は Mac をライトモードにして作業することをおすすめします。

Asset Catalog で後からその色を編集しても View 側の色が変わらない 
(iOS 12以下のみ)

既に xib/Storyboard の View に設定した色を Asset Catalog で編集しても iOS 12 以下で色が変わらない問題があります。

先程のライトモードの色を赤から緑に変更してみます。



すると以下のようなの表示になります。

image.png

左が iOS 12 で 右が iOS 13 です。iOS 12 の色が変更前の色になっていますね... :disappointed_relieved:

対策

色を後から変更しても一部変わらない箇所があるため、
参照先の xib/Storyboard の色を再設定したほうが良いです。
どこか一箇所の色を一旦別の色にし、
また元の色に戻すとそのファイル内の色は全部正しい色に更新されました。

(おまけ) スプラッシュスクリーンの
画像が更新されない問題

スプラッシュスクリーンの画像を
ダークモード対応のために差し替えても
画像が更新されず、古い画像が出てしまうケースがありました。

こちらは昔からある問題で、LaunchScreen の Storyboard に 
xcassets の画像を設定していると起きるようです。

参考
Launch Screen Image not Updating!!!
https://forums.developer.apple.com/thread/68244

まとめ

iOS 12 以下をサポートする場合、現状のダークモード対応にはかなり課題があります。
それでもユーザーが期待している機能であることは間違いないので、上記の問題などを気をつけつつ、iOS 12 以下の端末での動作検証をしっかりと行い、リリースするようにしましょう。

上記の問題は https://developer.apple.com/bug-reporting/ で報告済みなので、なる早で直ることを期待しています。

検証に利用したリポジトリはこちらに置いておきます。
https://github.com/tattn/WeirdDarkMode-iOS12


  1. この文章内では簡易化のため Asset Catalog で作成した色のことをカスタム色と記載します 

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

CombineのPublisherをテストするためのexpect/expectErrorメソッドを作成した

おそらく現在はユニットテストでCombineのPublisherの値を検査するためにはXCTestExpectationを駆使したりしないといけません(簡便な方法があったら教えて欲しいです。)。さらに私はSwiftCheckを利用しているので検査結果をBool値で取得したいという要求があります。

ですのでテストターゲットでexpectとexpectErrorという検査メソッドをPublisherに追加しました。

import XCTest
import Combine

public extension Publisher where Output: Equatable {
    @discardableResult
    func expect(_ expectedValue: Output,
                takesNewest: Bool = false,
                timeout: TimeInterval = 2.0,
                fulfillmentCount: Int = 1,
                file: StaticString = #file,
                function: StaticString = #function,
                line: UInt = #line) -> Bool {
        var result                 = false
        var actualValues           = [Output]()
        var actualFulfillmentCount = 0
        var cancellables           = Set<AnyCancellable>()
        let exp                    = XCTestExpectation()
        exp.expectedFulfillmentCount = fulfillmentCount
        exp.assertForOverFulfill = true

        let waiter = XCTWaiter()

        self.receive(on: RunLoop.main).sink(receiveCompletion: { _ in }, receiveValue: {
            if takesNewest || !result {
                result = $0 == expectedValue
            }
            actualValues.append($0)
            actualFulfillmentCount += 1
            exp.fulfill()
        }).store(in: &cancellables)

        _ = waiter.wait(for: [exp], timeout: timeout)

        XCTAssertLessThanOrEqual(fulfillmentCount,
                                 actualFulfillmentCount,
                                 "\(file) - \(function):\(line): Expectation is short of fulfillment. '\(fulfillmentCount)' expected, but '\(actualFulfillmentCount)'.")

        XCTAssertTrue(result,
                      "\(file) - \(function):\(line): Expected output is '\(String(describing: expectedValue))', but actual stream is '\(String(describing: actualValues))'")

        return result && fulfillmentCount <= actualFulfillmentCount
    }
}

public extension Publisher where Failure: Equatable {
    @discardableResult
    func expectError(_ expectedError: Failure,
                     timeout: TimeInterval = 2.0,
                     file: StaticString = #file,
                     function: StaticString = #function,
                     line: UInt = #line) -> Bool {
        var actualError: Failure?
        var cancellables = Set<AnyCancellable>()
        let exp          = XCTestExpectation()

        let waiter = XCTWaiter()

        self.receive(on: RunLoop.main).sink(receiveCompletion: { completion in
            switch completion {
            case .failure(let receivedError):
                actualError = receivedError
            default: ()
            }

            exp.fulfill()
        }, receiveValue: { _ in }).store(in: &cancellables)

        _ = waiter.wait(for: [exp], timeout: timeout)

        XCTAssertEqual(expectedError, actualError,
                       "\(file) - \(function):\(line): Expected error is '\(String(describing: expectedError))', but actual error is '\(String(describing: actualError))'")

        return expectedError == actualError
    }
}

expectする値が取得できれば成功、できなければ失敗、そして結果のBool値を返すようになっています。
timeoutは値を取得する制限時間です。
expectのtakesNewestは、trueであればストリームの最新の値まで全て検査し、過去に該当する値があっても結果を上書きします。fulfillmentCountは何回値を取得するかを設定します。

テストで利用するには以下のように記述します。

class MyTests: XCTestCase {
    class P: Publisher {
        typealias Output = Int
        typealias Failure = Never

        func receive<S>(subscriber: S) where S : Subscriber, P.Failure == S.Failure, P.Output == S.Input {
            DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
                subscriber.receive(1)
                subscriber.receive(2)
                subscriber.receive(completion: .finished)
            }

            let subscription = Subscriptions.empty
            subscriber.receive(subscription: subscription)
        }
    }

    class Q: Publisher {
        typealias Output = Int
        typealias Failure = MyError

        func receive<S>(subscriber: S) where S : Subscriber, Q.Failure == S.Failure, Q.Output == S.Input {
            DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
                subscriber.receive(completion: .failure(.error1))
            }

            let subscription = Subscriptions.empty
            subscriber.receive(subscription: subscription)
        }
    }


    enum MyError: Error, Equatable {
        case error1
        case error2
    }


    func testExpect() {
        P().expect(1)
        P().buffer(size: 2, prefetch: .keepFull, whenFull: .dropOldest).collect().expect([1, 2])

        P().expect(1, takesNewest: true, fulfillmentCount: 2) // failed
        P().expect(3) // failed
    }

    func testExpectError() {
        Q().expectError(.error1)
    }
}

私のプロジェクトでは一応動作しているようです。
バグやより良い実装があれば教えてください。

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

【Swift】 ボタン押下時に画面にViewを追加する

はじめに

会社で提供してるWebサービス専用のタブブラウザを作れないか、というコトで、実現性評価を何段階かに分けてやってるうちの1つ。
ボタン押下時にUIViewを画面に追加する。

開発環境

端末:MacBook Pro/MacOS 10.14.5(Mojave)
Xcode:10.2.1
Swift:5

やったこと

ボタン押下時に、画面のランダムな場所に10px*10pxのUIViewを表示。
 →ボタンを押下するたびにどんどんUIViewを追加していく。

実装

画面イメージ
起動時
ボタン連打

※色と場所がランダムに設定されたUIViewがボタンを押すたびに画面に配置されていく。

ソースサンプル
ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var btnCreateView: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.

        btnCreateView.center = view.center
    }

    @IBAction func createView(_ sender: Any) {
        let testView: UIView = UIView()

        let viewWidth = UIScreen.main.bounds.width
        let viewHeight = UIScreen.main.bounds.height

        let randX = CGFloat(Int.random(in: 0 ... Int(viewWidth)))
        let randY = CGFloat(Int.random(in: 0 ... Int(viewHeight)))

        let randRed = CGFloat(Int.random(in: 0 ... 255))
        let randGreen = CGFloat(Int.random(in: 0 ... 255))
        let randBlue = CGFloat(Int.random(in: 0 ... 255))

        testView.backgroundColor = .init(red: randRed/255,
                                         green: randGreen/255,
                                         blue: randBlue/255,
                                         alpha: 1)

        testView.frame = CGRect.init(x: randX, y: randY, width: 10, height: 10)

        view.addSubview(testView)
    }
}

感想等

実現性評価というか、まぁこうなるだろうな〜ってイメージを順当に形にした感じです。

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

SwiftUIでList内のViewは複数の遷移先を持つことができない

iOSアプリを開発する上で、UIKitのUITableViewCellでタップしたUIViewによって遷移先を変えるというのは、よくあると思います。
同じことをSwiftUIの ListNavigationView で実現しようとしましたが、うまくいかなかったのですが、その際に試したことを備忘録として残しておきます。
環境はXcode11.0 (11A420a)、iOS13.0です。

NavigationLink を2つ指定した場合

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                Row(city: "Twentynine Palms", state: "California")
                Row(city: "Port Alsworth", state: "Alaska")
                Row(city: "Skagway", state: "Alaska")
            }
        }
    }
}

struct Row: View {
    var city: String
    var state: String
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            NavigationLink(city, destination: Text(city))
            NavigationLink(state, destination: Text(state))
        }
    }
}

Pushの遷移を行う場合は NavigationLink 使用するので、素直に実装すると、このようになるかと思います。
これをビルドすると以下のような挙動になりました。

Oct-01-2019 23-29-42.gif

1回のタップで遷移を2回おこなっていて、意図した挙動になっていません。(また表示自体も disclosureIndicator が2つ表示されてしまっています)

Button を2つ配置して NavigationLink.init(destination:isActive:label:) を使用した場合

NavigationLink のインターフェイスを調べているとNavigationLink.init(destination:isActive:label:) というメソッドが定義されています。
ドキュメントには定義以上の情報がないですが、利用できそうです。

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                Row(city: "Twentynine Palms", state: "California")
                Row(city: "Port Alsworth", state: "Alaska")
                Row(city: "Skagway", state: "Alaska")
            }
        }
    }
}

struct Row: View {
    var city: String
    var state: String
    @State var buttonDidTap = (false, "")
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Button(city) {
                self.buttonDidTap = (true, self.city)
            }
            Button(state) {
                self.buttonDidTap = (true, self.state)
            }
            NavigationLink(destination: Text(buttonDidTap.1), isActive: $buttonDidTap.0) {
                EmptyView()
            }
        }
    }
}

実装は上記のように各ボタンのアクションを NavigationLink にバインドしています。

Oct-02-2019 00-08-27.gif

一見上手く遷移できているようですが、どのボタンをタップしても2番目にあるボタンの遷移先になってしまっています。
各ボタンのアクションにブレイクポイントを貼って、もう少し挙動を確認してみます。

Oct-02-2019 00-16-05.gif

ボタンを1つだけタップしているはずですが、各ボタンのアクションが実行されてしまっています。
そのため最後のボタンイベントが優先されてしまい、このような挙動になってしまっているようです。

最後に

上記のように、素直な方法では実現できませんでした。
そのため、Listを用いた一覧画面は遷移を1つに絞り、詳細画面で複数の遷移先を持つデザインに変更して、解決することにしました。

もし他に良い方法を知っている方がいれば教えてほしいです。(UIKitを使えば色々な解決方法はありそうですよね)

参考資料

https://developer.apple.com/documentation/swiftui/navigationlink/3364630-init

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

【iOS】AESで暗号化・復号する方法(CryptoSwift)

背景

APIと連携するようなサーバー通信時には、平文ではなく暗号化したい。
そこで、CryptoSwiftを用いて簡単に暗号化・復号できるらしいので試してみました。

iOSアプリでAESでの暗号化のためCryptoSwiftを使用した
↓↓↓↓↓↓↓↓
自分の記事を作成中にCryptoSwiftの良記事を見つけてしまったので、メモレベルで記載します。笑

AESとは

暗号化のやり方の一つ
2018年現在のアメリカで標準として採用されているやり方
共通鍵暗号方式
鍵長が128ビット、192ビット、256ビットから選ぶことができる
WPA2で標準で採用されているやり方
WPAで使われる場合もある
引用:iOSアプリでAESでの暗号化のためCryptoSwiftを使用した

バージョン

  • Xcode 10.2.1
  • Swift 5.0.1
  • CryptoSwift 1.0.0

CryptoSwiftライブラリを導入する

CocoaPodsを用いてCryptoSwiftを導入します。Podfileは以下になります。

Podfile
target 'Qiita_AES' do
  use_frameworks!
  # CryptoSwiftライブラリを追加する
  pod 'CryptoSwift'
end

CocoaPodsの導入方法がわからない場合は、こちらの記事を参考にしてください。
【Swift】CocoaPods導入手順

暗号化・復号用のEncryptionAESクラスを実装

EncryptionAES.swift
import Foundation
import CryptoSwift

class EncryptionAES {

    // 暗号化処理
    // key:変換キー
    // iv:初期化ベクトル(時間などを使用してランダム生成するとよりセキュアになる)
    // text:文字列
    func encrypt(key: String, iv:String, text:String) -> String {

        do {
            // 暗号化処理
            // AES インスタンス化
            let aes = try AES(key: key, iv: iv)
            let encrypt = try aes.encrypt(Array(text.utf8))

            // Data 型変換
            let data = Data( encrypt )
            // base64 変換
            let base64Data = data.base64EncodedData()
            // UTF-8変換 nil 不可
            guard let base64String =
                String(data: base64Data as Data, encoding: String.Encoding.utf8)
                else { return "" }

            // base64文字列
            return base64String

        } catch {
            // エラー処理
            return ""
        }
    }

    // 複合処理
    // key:変換キー
    // iv:?(時間などを使用してランダム生成するとよりセキュアになる)
    // base64:文字列
    func decrypt(key: String, iv:String, base64:String) -> String {

        do {
            // AES インスタンス化
            let aes = try AES(key: key, iv:iv)

            // base64 から Data型へ
            let byteData = base64.data(using: String.Encoding.utf8)! as Data
            // base64 デーコード
            guard let data = Data(base64Encoded: byteData)
                else { return "" }

            // UInt8 配列の作成
            let aBuffer = Array<UInt8>(data)
            // AES 複合
            let decrypted = try aes.decrypt(aBuffer)
            // UTF-8変換
            guard let text = String(data: Data(decrypted), encoding: .utf8)
                else { return "" }

            return text
        } catch {
            // エラー処理
            return ""
        }
    }
}

呼び出し方法

let key = "abcdefghijklmnop" // 128bit(16文字)のキーを入れる
let iv =  "1234567890123456" // データをシフト演算するキー128bit(16文字)
let json = "{\"id\":2,\"name\":\"ほげほげ君\",\"hobby\":\"ボルダリング\"}" // 暗号化・復号するjson

// EncryptionAESのインスタンス化
let aes = EncryptionAES()

// jsonを暗号化
let jsonEncrypted = try! aes.encrypt(key: key, iv: iv, text: json)
print(jsonEncrypted)
// -> PlOEf5oeBt2GsyWDHUk7OtOUV6nRPpYQvqE919jkrrpWaAvQc+IhkrO3gPmiNQbkQOLLXMlnQh5U2wbwEPYksg==

// jsonを復号
let jsonDecrypted = aes.decrypt(key: key, iv: iv, base64: jsonEncrypted)
print(jsonDecrypted)
// -> {"id":2,"name":"ほげほげ君","hobby":"ボルダリング"}

まとめ

暗号化・復号と聞くと実装が大変なイメージでしたが、CryptoSwiftなら簡単にできるのでおすすめです。^^

以上になります。
もし不明点や間違い等があればコメントくださいm(_ _)m

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