20200709のiOSに関する記事は14件です。

Diffable Data Sourceでセルをリロードする方法

Diffable Data Sourceとセルのリロード

iOS 13以降、UITableViewやUICollectionViewで使えるDiffable Data Sourceですが、WWDCのセッションやAppleのサンプルを参考に実装すると、あるセルの内容を更新したいときにうまくいかない場合があります。

ここではその問題を回避する方法をいくつか紹介します。

なお、Diffable Data Sourceそのものについては、以下を見てもらうのがわかりやすいかと思います。

うまくいかないパターン

SnapshotItemIdentifierTypeHashable でさえあればなんでも構いません。WWDCのセッションやそこで参照されているサンプルでは、セルに表示するデータも含めたアイテムのstructを作ってそれを ItemIdentifierType としています。そうすることで、セルを構築する際に必要なものがそこに揃っているので便利です。

話を単純にするため、UITableViewにユーザー名を表示するという例で説明します。

Screenshot

今回の例では、ユーザーは整数のIDとユーザー名で構成されるものとして、次のようにstructを定義します。

struct User: Hashable {
    let id: Int
    let name: String

    static func == (lhs: User, rhs: User) -> Bool {
        return lhs.id == rhs.id
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

Snapshotはこんな感じで構築します。

var snapshot = NSDiffableDataSourceSnapshot<Section, User>()
snapshot.appendSections([.first])
snapshot.appendItems([
    User(id: 1, name: "Henry"),
    User(id: 2, name: "Thomas"),
    User(id: 3, name: "Percy"),
], toSection: .first)
dataSource.apply(snapshot, animatingDifferences: false)

このSnapshotを適用しているData Sourceは、次のような感じです。
セルを構築する際に、そこに表示するユーザー名として Username がそのまま使えるので便利ですね。

dataSource = UITableViewDiffableDataSource(tableView: tableView,
                                           cellProvider: { (tableView, indexPath, user) in
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    cell.textLabel?.text = user.name
    return cell
})

さて、ここでボタンが押されると、次のSnapshotを適用してみるようにします。
要は2番のidを持つユーザーの名前を変更しています。

var snapshot = NSDiffableDataSourceSnapshot<Section, User>()
snapshot.appendSections([.first])
snapshot.appendItems([
    User(id: 1, name: "Henry"),
    User(id: 2, name: "Tommy"),    // be modified
    User(id: 3, name: "Percy"),
], toSection: .first)
dataSource.apply(snapshot, animatingDifferences: true)

期待される動作は、Thomasと表示されていた2行目がTommyに変更されることですが、これはうまくいきません。Thomasのままになります :frowning2:

これを解決していきましょう。

解決策1: idだけで同値性を判定しない

さて、なぜ期待通りに動作しないのでしょうか。
それは、 User の同値性はidのみで比較されているからです。

static func == (lhs: User, rhs: User) -> Bool {
    return lhs.id == rhs.id
}

この同値性の評価においては、 name は存在しないと考えても同じです。
User(id: 2, name: "Thomas") == User(id: 2, name: "Tommy") を、 User(id: 2) == User(id: 2) と考えればいいでしょう。当然、 同じもの だと判定されます。同じなら更新されないのは当たり前です。

そこで、これを別物として扱ってやれば、更新はされるようになります。

struct User: Hashable {
    let id: Int
    let name: String

//    static func == (lhs: User, rhs: User) -> Bool {
//        return lhs.id == rhs.id
//    }
//
//    func hash(into hasher: inout Hasher) {
//        hasher.combine(id)
//    }
}

SwiftのstructはStoredプロパティがすべて Hashable なら、自動でそれらをすべて比較する ==hash(into:) が生成されるのでこれで構いません。

これで、 User(id: 2, name: "Thomas")User(id: 2, name: "Tommy") は別物になったので、ちゃんと表示が更新されるようになります。

ただ、アニメーションさせるとわかるのですが、Thomasの行が削除されて、Tommyの行が追加されたという扱いになるので少し違和感が残ります。

解決策2: リロードさせる

実はSnapshotには、特定のアイテムをリロードさせるメソッドが用意されています。

mutating func reloadItems(_ identifiers: [ItemIdentifierType])

なんだ、最初からこれを使えばよかったのか!
そこで User は元のidでのみ比較するように戻して、ボタンが押された時の処理を次のようにしてみます。すると…ちっとも更新されません :cry:

var snapshot = dataSource.snapshot()
snapshot.reloadItems([User(id: 2, name: "Tommy")])
dataSource.apply(snapshot, animatingDifferences: true)

なぜでしょうか。
実はリロードはされているのです。しかし、セルを構築する際に呼ばれる以下のクロージャーで user.name がThomasのままなのです。

cellProvider: { (tableView, indexPath, user) in
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    cell.textLabel?.text = user.name // Thomas
    return cell
}

落ち着いて考えてみましょう。
Snapshotの ItemIdentifierType は名前が示すとおりアイテムの識別子となる型です。ですから、 reloadItems() に渡すのはリロードしたいセルの識別子(の配列)です。

snapshot.reloadItems([User(id: 2, name: "Tommy")])

これは単にidが2番のものをリロードしてくれと言っているだけです(同値性の判定はidのみに戻しています)。 持っているデータを更新してくれと言っているわけではありません。

Data SourceやSnapshotに渡すのはあくまでも識別子であってデータそのものではありません。ただ、データも識別子の一部として一緒に持っておくと便利だからそうしているだけです。識別子としては変更がないのにデータを変更したいという考えが間違っているように思います。

そこで、識別子とデータを別に管理するようにしてみます。まず、識別子とデータに分けます。
※以下の例はちょっとバカっぽいですが、現実のUserには名前以外にも他にいろいろデータがあると思います。

struct UserID: Hashable {
    let id: Int
}

struct User {
    let name: String
}

Snapshotに渡すのは識別子、つまり UserID です。

var snapshot = NSDiffableDataSourceSnapshot<Section, UserID>()
snapshot.appendSections([.first])
snapshot.appendItems([
    UserID(id: 1),
    UserID(id: 2),
    UserID(id: 3),
], toSection: .first)
dataSource.apply(snapshot, animatingDifferences: false)

識別子からデータを取れるようにしましょう。

private var users: [UserID: User] = [
    UserID(id: 1): User(name: "Henry"),
    UserID(id: 2): User(name: "Thomas"),
    UserID(id: 3): User(name: "Percy"),
]

セルを構築するときはここからデータを取得します。

dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] (tableView, indexPath, userID) in
    guard let self = self else { return nil }
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    cell.textLabel?.text = self.users[userID]?.name
    return cell
})

このようにしておけば、識別子は更新しなくてもデータは更新できます。

let userID = UserID(id: 2)

// データを更新
users[userID] = User(name: "Tommy")

// リロード要求
var snapshot = dataSource.snapshot()
snapshot.reloadItems([userID])
dataSource.apply(snapshot, animatingDifferences: true)

これで期待する動きになります。
ただ、データを別に管理しなきゃいけなくなりました。

解決策3: 識別子からデータを「参照」させる

もともとデータをどこかで管理しているのなら解決策2は素直でいいんじゃないかなと思っているのですが、最初の(うまくいかないパターンのように)識別子の方にデータを持たせたい場合もあると思います。
そこで識別子にデータを持たせるのではなく、識別子からデータを参照させましょう。識別子自体は更新せずに、識別子に参照させたものを更新するようにします。

User のstructは次のようにします。

struct User: Hashable {
    let id: Int

    class Info {
        var name: String
        init(name: String) {
            self.name = name
        }
    }
    let info: Info

    init(id: Int, info: Info) {
        self.id = id
        self.info = info
    }

    static func == (lhs: User, rhs: User) -> Bool {
        return lhs.id == rhs.id
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

違いは、id以外の情報(といってもここではnameしかないですが)を Info という class に入れて、それを参照するようにしました。 Info はstructではダメです。classにして参照型にする必要があります。

こうすることで、次のように User.info 自体は変更せずに Info の中身を更新することができます。(userlet になっていて変更してないことに注目)

var snapshot = dataSource.snapshot()

guard let user = snapshot.itemIdentifiers(inSection: .first)
    .first(where: { $0.id == 2 }) else { return }
user.info.name = "Tommy"

snapshot.reloadItems([user])
dataSource.apply(snapshot, animatingDifferences: true)

まとめ

Diffable Data Sourceでセルをリロードさせたいなら次の方法が考えられます。
それぞれ長所・短所はあるので、どれが一番いいというのは一概には言えないと思います。

  • 別の識別子であると認識させる(厳密にはリロードではない)
  • 識別子とは別にデータを管理しておく
  • 識別子からデータを「参照」させる

なお、今回の検証のために作ったプログラム全体はGitHubに置いてあります。
https://github.com/hironytic/DiffableDataSourceReload

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

macOS CatalinaにアップデートしたらCocoaPodsが動かなくなったときの対処

pod installでエラー

$ pod install
-bash: /usr/local/bin/pod: /System/Library/Frameworks/Ruby.framework/Versions/2.3/usr/bin/ruby: bad interpreter: No such file or directory

pod install で上記エラーが発生するようになりました。タイトルにはCatalinaにアップデート後としましたが、本当にOSアップデートが原因かはわかっておりません。
このときの対処が

sudo gem install cocoapods -n /usr/local/bin

でパスを指定してCocoaPodsを再インストールして正常に動くようになりました。
参考:[Xcode]Mac OS Catalinaにアップデートしたらpodコマンドが通らなくなった

ライブラリの追加がうまく動かない

このあとライブラリを新規に追加しようとすると

[!] Oh no, an error occurred.

というエラーが表示される(上下にも色々ありますが、詳細な原因はわからない)
調べたところレポジトリが壊れているようなので

rm -rf ~/.cocoapods/repos/trunk/

でリポジトリを削除します。
するとライブラリの追加も問題なくなりました。
参考:pod install fails with json error on Mac OS X 10.15

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

【iOS】UIWebViewの削除のためにやったこと

UIWebViewの廃止

iOSアプリからUIWebViewを2020年12月までに削除することが必須となりました。
Updating Apps that Use Web Views

そのために開発している古いアプリからUIWebViewを削除するためにやったことを書いていきます。当然これで対応が全てではない(どころかほんのちょっと)のでその点だけ注意してください。古いコードなのでObjective-Cのみ対応しました。

どこでUIWebViewが使用されているのか

ここでプロジェクト内検索をかけました。

$ cd yourprojectpath
$ grep -r "UIWebView" .

これでUIWebViewが使われているところを検索しました
参考:How to search for any UIWebView component usage inside a current project?

電話を掛けるときに利用していたUIWebViewの削除

知らなかったのですが、UIWebViewから電話を掛ける画面の表示が可能なようです。その実装を置き換えていきます。

UIWebViewの場合

UIWebView *callWebview =[[UIWebView alloc] init];
NSURL *phoneNumberURL =[NSURL URLWithString:[NSString stringWithFormat:@"tel://%@", @"09011111111"]];
[callWebview loadRequest:[NSURLRequest requestWithURL:phoneNumberURL]];
[self.view addSubview:callWebview];

UIWebViewを使わない場合

NSURL *telURL =[NSURL URLWithString:[NSString stringWithFormat:@"tel://%@", @"09011111111"]];
[[UIApplication sharedApplication] openURL: telURL];

WKWebViewへのリプレイス

単純にWKWebViewに置き換えていきます。

#import <WebKit/WebKit.h>
-    UIWebView *web = [[UIWebView alloc]initWithFrame:CGRectMake(0,0,ScreenWidth, ScreenHeight)];
+    WKWebView *web = [[WKWebView alloc]initWithFrame:CGRectMake(0,0,ScreenWidth, ScreenHeight)];

WEBページのアラートやpromptが表示されない

単純にWKWebViewに置き換えただけだとWEBページのalert、confirm、promptが表示されなくなります。WKUIDelegateのプロトコルに準拠する必要があります。
Alert等の表示は都度確認して表示するのかしないのか、またはカスタマイズして表示することが可能となります。

@interface HogeViewController () <WKUIDelegate>
// Alertの表示
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler 
{
}

// Confirmの表示
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler
{  
}

// Promptの表示
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * __nullable result))completionHandler
{    
}

ここの実装はこちらの記事を参考にいたしました
参考:WKWebViewでJavaScript(Alert,Confirm,Prompt)の処理
単純に表示するのであればコピペで十分かと思います。

その他ライブラリのアップデート

CocoaPods、Carthageで使用されているライブラリでUIWebViewを利用しているものがあったため、削除・アップデートを掛ける部分がありました。
以上で作業は完了です。

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

【Swift】XcodeのAttributes Inspectorにある画像をコードで表示する(SF Symbols)

はじめに

XcodeのAttributes Inspectorにある画像をコードで表示させる方法が分からなかったので記載します。↓にある画像です。
ちなみに、これは SF Symbols といい、iOS 13から追加されたものになります。
詳しくはこちらのドキュメントをご覧ください。

スクリーンショット 2020-07-09 16.26.37.png

環境

  • Swift:5.1.3
  • Xcode:11.3.1

実装

UIImage(systemName: "画像名") で実装できます。
例えば、UIButtonにゴミ箱の画像を表示させるには、このように書きます。

SampleViewController.swift
let trashButton = UIButton()
trashButton.setImage(UIImage(systemName: "trash"), for: .normal)
trashButton.sizeToFit()
trashButton.center = self.view.center
self.view.addSubview(trashButton)

スクリーンショット 2020-07-09 16.57.51.png

参考記事

iOSアプリでSystem Imageを使用する(iOS13以降)

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

deeplinkのstateパラメーターについて

今回はdeeplinkにおけるstateパラメーターのチェックについてお話します。

OAuth周りでstateパラメーターのチェックをすることがあります。
stateについては以下を参照してください。

[OAuthやOpenID Connectで使われるstateパラメーターについて]
https://tech-lab.sios.jp/archives/8492

リダイレクトする際に注意することも多いと思いますが、アプリのdeeplinkでauth_codeとか送る際にも同様のことが言えます。

hoge://success?auth_code=XXX

みたいにauth_codeのみdeeplinkで送っていた場合、攻撃者が自分のauth_codeをdeeplinkで送り込んだりできてしまうためです。
そのため、deeplinkでもstateを送り

hoge://success?auth_code=XXX&state=YYY

アプリ内部で発行したstateと一致することチェックすることで攻撃者が自分のauth_codeをdeeplinkで送ることを防げます

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

�【Flutter】もう怖くない!アプリ内課金・定期購入機能を実装する方法を丁寧に説明してみた。

トップ2.jpg

7月にFlutter開発を始めてから2作目、アイデアを発想するためのメモアプリ「IdeaShuffleMemo」をリリースしました。
走り書きをする感覚でサッとメモができ、さらにそのメモを組み合わせてシャッフルして表示したり、ランダムで過去のメモをピックアップしたり、アイデアのヒントになるようなワードを表示したり、アプリのアイデア出しにぴったりなアプリです。

■AppStore
https://apps.apple.com/jp/app/id1517535550

■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja

■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/

さて、このアプリを作成するにあたり、3つの機能を実装しようと取り組みました。この記事では、その中で定期購入・購読する方法を書いていきたいと思います。

■パスワードロック機能実装ついてはこちら
https://qiita.com/YuKiO-OO/items/bf2d1d107d1a66211619

■バックアップ機能実装についてはこちら
https://qiita.com/YuKiO-OO/items/67b471e6be6c4c4c26e9

アプリを作るなら、どうせなら稼げるアプリを作りたいですよね。

ただ、有料課金、特に定期購入・購読はハードルが高いな〜と尻込みしていませんか?

今回は、できるかぎり噛み砕いて、定期購入・購買機能を実装した方法を紹介していきたいと思います。

実現したいアプリ内課金機能

iOSとアンドロイドユーザーに対して、月額、年額の自動更新されるアプリ内課金・定期購入・購読(サプスクリプション 以降サブスク)に提供することを目的にします。

提供するサービス形態

サブスク機能実装の前に考えてないといけないのが、有料で提供するサービスの形態です。
今回は、アプリの拡張機能をサブスクで提供することを前提にします。

例えばWebサービスでも提供しているような、アプリではなくコンテンツで提供しているサービス(例えばネットフリックス、スタディサプリ)の場合は今回の方法では対応ができません。

アプリの有料課金について調べていると、コンテンツ提供のパターンでの有料課金を説明している場合が多いです。
コンテンツ提供パターンの場合、課金状態をどのツールからでも確認する必要があるので、外部に購入履歴(レシート)を保存して、ユーザーと紐づけ。
課金状態の変化を常にチェックする必要もあったりと、かなり複雑な構成になります。 → だからよく分からなくなる。

今回はアプリの拡張機能をサブスクで提供するパターンので、外部サーバーとの連携は購入履歴のチェックのみとシンプルな構成になります。

購入履歴(レシート)とサーバーの連携

アプリの有料課金について調べていると「レシートの確認、レシートの正当性」のようにレシートという言葉が頻繁に登場します。

「白くて細長い紙」のイメージが頭に浮かびますが、実際に紙が発行されるわけではありません。
レシートとは、購入履歴のデータの事です。(レシートと書いて説明すると分かりづらいので、これから先は購入履歴で統一します)

消費型でもサブスク型でも、基本は一緒で、各アプリストアにそのユーザーの購入履歴を問い合わせて、それが有効と確認できたら。対応するサービスを提供する流れになります。

ただ、ここで一つ問題があります。

アプリ内で購入履歴のチェックすることもできるのですが、仮にアプリ内で購入履歴を偽造されてしまった場合・・・

不正に有償サービスが使用されることになります。

それは困るので、いったん購入履歴を外部サーバー(ここでFirebaseが登場)に送ります。
外部サーバーからストアに購入履歴を問い合わせて、正当な購入履歴だと判断とされたら「この購入履歴はOKです!」と返してもらう処理をします。

この一連の処理は、ストア側で用意されておらず、こちらで用意する必要があります。

サブスク更新のチェック

サブスクを実装する上でもう一つ考えるべきことが、「サブスク更新のチェック」です。

サブスク更新していないユーザーに、有料機能を提供し続けると損することになります。

そのため、サブスクが有効期限切れの場合には、有料機能をOFFにする処理も必要です。

今回リリースしたアプリでは、サブスクの説明ページを開いたタイミングと、アプリ内で一定の条件が揃った場合にサブスク更新のチェックを走らせるようにしました。(セキュリティーの観点から条件は伏せておきます)

本来であればリアルタイムでの検知が望ましいですが、アプリ単独でサービスが完結するのでリアルタイムの重要性が低いですし、少し長めに使えてしまっても、サービスでいいかなと思いました。

だから、何回もストアに問い合わせするのも重くなるので、リアルタイムではチェックさせていません。

リアルタイムで課金状態を反映したい場合は、サーバーで処理をしたり、ストアから更新があった場合に通知するように設定する必要があります。

全体の流れ

事前準備

  • 各ストアにサブスクアイテムの作成・登録
  • 購入履歴のチェック用サーバーの設定

アプリ内の処理

  • 各ストアにユーザーがログイン
  • サブスクアイテムのデータを読みこんで表示(自動でローカルの通貨に変更になる)
  • ユーザーが購入処理。(ボタンをおす)
  • ストアから最新の購入履歴を取得
  • 購入履歴をサーバーに飛ばす
  • サーバーで購入履歴をチェック
  • OKだったら、有料機能を提供
  • 時々、課金状態のチェック

注意事項

サブスク機能はアプリが完成してから実装するのがおすすめ

後ほど説明しますが、サブスク機能の実装は、ほぼアプリが完成した最後のタイミングで実装した方がいいと思います。

特にiOSの方が面倒だからです。

iOSの課金テストでは、Sandboxユーザーというテストユーザーを使います。

1ヶ月が5分、年間が30分と短くなるのですが、定期購入が5回までしか自動更新されず、それ以降課金テストができなくなります。(私の場合なのかもしれませんが・・・)
他にエラーが残っている状態でテストしたりすると、何個もSandBoxアカウント(架空のメアドでOK)を作る必要があって面倒です。

またアンドロイドの場合は、アルファもしくはベータにバイナリ(アプリのデータ)をGoogle Play Consoleにアップロードして審査を通過してからでないと、課金のテストができません。

つまり、未完成だと色々面倒ってことです。

iOSの課金テストは実機が必要

iOSの課金テストは実機が必要です。実機をさわれない場合にはチェックができません。シュミレーターでは課金アイテムの表示まではできます。
ちなみに私は最終テスト用のTestFlightでないと課金テストができないと思っていたので、無駄にバージョンを刻んでしまいましたが、TestFlightまで進める必要はありません。

その他

※20年6月時点での情報をもとに作成しています。
※試行錯誤の結果、まだリファクタリング等できていません。処理が冗長的なところや一部無駄な処理もありますので、ご了承ください。

導入するパッケージ

in_app_purchase
in_app_purchase | Flutter Package

こちらがFlutter公式の課金用のパッケージのようです。
もう一つあるようなのですが、今後を見据えてこちらを採用しました。
初期設定はGet Startを読んでください。

参考記事(教科書)

in_app_purchaseが用意している下記のGitHubのサンプルコードを改造していくのがメインになります。こちらが今回の教科書です。

plugins/main.dart at master · flutter/plugins · GitHub

もう一つがサーバー側の処理としては下記の記事を参考にさせて頂きました。分かりやすくて素晴らしい記事でした。
【Flutter + Firebase】アプリ内課金(IAP)のステップバイステップ実装ガイド【レシート検証】 | taketiyo.log

鬼門のiOSのレシート関連はこちらが分かりやすかったですね。
AppStoreが返してくる購入履歴がどんなものか理解できると思います。
https://qiita.com/Masataka-n/items/6f98a5a9fee7b28ccd1f

サブスク課金機能の実装

前提の説明が長くなりましたが、早速、サブスク課金の実装を進めていきます。

各ストアの設定

まず教科書はこちら。
in_app_purchase
in_app_purchase | Flutter Package

まず面倒ですが、サブスク課金ができるようにパッケージのインストールを済ませたら、各ストアで サブスクアイテムを追加します。

この記事内にあるGet Startをまずやってみてください。

サブスクアイテムの作成

AppStoreの場合

https://help.apple.com/app-store-connect/?lang=ja-jp#/devae49fb316
この記事を参考にすれば作成は難しくないはずです。

作成途中ステータスにメタデータがないと表示されるので、それがなくなるように各項目を入力していきます。

プロモーションオプションでは課金アイテムのアイコンを設定できますが、アプリのアイコンと同一はNGでした。またアイコン内の文字が見えづらかったり、小さいと却下くらいます。私はアプリのアイコンの上に重ねるようにプラン名を大きく記載しました。

審査に関する情報という項目があって、スクリーンショットを添付する必要があるのですが、ここは課金画面をスクショしてアップロードしたら良いようです。コメントには特に何も記載していません。

この時に、有料課金の詳細、プラン名と金額、利用規約とプライバシーポリシーがないと却下くらうみたいです。あと、AppStoreの説明にも書いておく必要があるので注意。

分からなかったら、僕のアプリを参考にしてみてください。

■AppStore
https://apps.apple.com/jp/app/id1517535550

GooglePlayの場合

サブスクアイテムを設定するには、APK(アプリのデータ)をアップロードする必要があります。
その際に、購入権限が許可されたAPKが必要と言われるので、下記をマニフェストに追加します。
ちなみにAPKの審査が通過してからでないと、アイテムが表示されないようです。

/android/app/src/main/AndroidManifest.xml
<!—  定期購入アイテム作成のために、請求権限を追加 —>
<uses-permission android:name=“com.android.vending.BILLING” />

iOSのサブスクアイテムの表示について

App Storeはバイナリ(アプリのデータ)をアップロードする必要はないとあったのですが、サブスクアイテムを登録後、3日ほど経っても表示されませんでした。

バイナリを登録したところ表示されたので、とりあえず一度バイナリをアップしておいた方がいいかもれません。

サブスクアイテムの表示の仕組み

in_app_purchaseが用意している下記のGitHubのサンプルコードを改造していくのがメインになります。こちらをまず読みこんでください。

plugins/main.dart at master · flutter/plugins · GitHub

定期購入・購読に表示する名前や金額はアプリ側で用意するのではなく、ストアからアイテムのデータを取得して表示します。

ストアから取得することによって、地域に合わせた通貨、ローカライズを登録しておけばローカライズされた名前と説明が表示されます。
Simulator Screen Shot - iPhone 11 Pro Max - 2020-06-17 at 16.35.27.png

だから、アプリ側で、金額やアイテム名などをローカライズをする必要がありません。

表示するアイテムの選択は以下の通り。

Github記事上部に書いてあるとおり、「_kProductIds」に、課金アイテムの製品ID(iOS)とアイテムID(Android)を設定します。

ここに記載されたIDのアイテムが取得されてきます。

//Githubに書いてあるサンプル
//それぞれ設定したIDを入力してください。
const String _kConsumableId = 'consumable';
const List<String> _kProductIds = <String>[
//以下にサンプルのidが記載されている
  _kConsumableId, //変数に予め入れておいた方が、あとでチェックなどしやすい。
  'upgrade', //これはサンプルなので、適宜IDに変えてください。
  'subscription'
];

両ストア同じIDにできるならいいのですが、同じにできない場合は下記のようにしてみてください。

List<String> _kProductIds = <String>[
  Platform.isIOS?'iosのid' : 'androidのid',
  Platform.isIOS?'iosのid' : 'androidのid',
];

ここで登録されたIDを元にデータが引っ張られてきます。

購入履歴の取得は、「initStoreInfo();」メソッド内でストアに問い合わせて、エラーがあった場合、データが空だった場合、過去の購入履歴のチェックなど、長い処理を経て、最後に「_products」に代入されます。

Future<void> initStoreInfo() async {
//省略
 await _connection.queryProductDetails(_kProductIds.toSet());
//省略
 _products = productDetailResponse.productDetails;
//省略
}

それから、「_buildProductList();」メソッドで取得してきたプロダクト情報を展開してカードとして表示しています。

購入処理について

購入ボタンが押された時

購入処理は、_buildProductList();内にある下記のところで処理されます。

 Card _buildProductList(){
//省略
//_productsをそれぞれ展開して、productDetailsで詳細を表示
  return ListTile(
            title: Text(
              productDetails.title,
            ),
            subtitle: Text(
              productDetails.description,
            ),

            trailing: previousPurchase != null
                ? Icon(Icons.check)
                : FlatButton(
                    child: Text(productDetails.price),
                    color: Colors.green[800],
                    textColor: Colors.white,
                    onPressed: () {
                      PurchaseParam purchaseParam = PurchaseParam(
                          productDetails: productDetails,
                          applicationUserName: null,
                          sandboxTesting: true);
//消費型の購入か非消費型の購入の処理の分岐
//サブスクのみの場合は、_connection.buyConsumableの処理と分岐は不要
                      if (productDetails.id == _kConsumableId) {
                        _connection.buyConsumable(
                            purchaseParam: purchaseParam,
                            autoConsume: kAutoConsume || Platform.isIOS);
                      } else 
//サブスクの場合はこれを残す
                        _connection.buyNonConsumable(
                            purchaseParam: purchaseParam);
                      }
                    },
                  ));

//省略

}

「_connection.buyConsumable」と「connection.buyNonConsumable」を分岐で処理してますが、今回はサブスクリプションなので、「connection.buyNonConsumable」の処理だけで大丈夫です。
今回は月額と年額の期間の差で、サービスに差がないので、特に商品の区別をする必要がありません。

購入処理の実行

この処理を実行する、コードの一番下にある「_listenToPurchaseUpdated]メソッドが発動します。

ここで処理中にローディング画面を表示したり、エラーが発生した場合はエラー処理をします。

次に 「_verifyPurchase」という処理で購入履歴の検証をしますが、この公式の例は鬼適当です。

この後に紹介する記事が丁寧に処理方法を紹介してくれるので、後ほど説明。

購入履歴が正統なものだったら「deliverProduct」が、正統なものと判断されない場合は「_handleInvalidPurchase」が呼ばれます。

「deliverProduct」では、別で作ってあるConsumableStoreのClassで、購入履歴を保存したりする処理をしています。
ただ、今回はサブスクのみであり、常に最新の購入履歴が期限内なのか確認する必要があるので、保存はしていません。
ローディング画面を閉じるくらいしか処理をしていません。有料機能の開放については、別の所で処理しています。

最後に、2つほど処理されます。

Androidnのみの処理は、Androidの方が消耗品タイプの場合、消費を通知しないと消費されたことにならないようです。

//最後から2番目の処理
   if (Platform.isAndroid) {
//month_planとyear_planにはそれぞれアイテムIDを代入済みです。
   if (!kAutoConsume && purchaseDetails.productID == _month_plan || purchaseDetails.productID == _year_plan) {
            await InAppPurchaseConnection.instance.consumePurchase(purchaseDetails);
       }
     }

最後の処理で、購入処理のトランザクジョン(一連の処理)を終了させます。

   if (purchaseDetails.pendingCompletePurchase) {
    await InAppPurchaseConnection.instance.completePurchase(purchaseDetails);
    }

また後で説明しますが、これがやっかいな問題を起こします。あとで書くはハマりポイントで紹介します。とりあえずこれはこれでOKです。

購入履歴の検証

「_verifyPurchase」の処理でレシートを検証します。
公式には詳細が書いていないので、下記の記事を参考にさせて頂きました。
素晴らしい記事をありがとうございます。

【Flutter + Firebase】アプリ内課金(IAP)のステップバイステップ実装ガイド【レシート検証】 | taketiyo.log

このパートでは上記記事を参考に実装していきます。

このパートでは大きくわけて3つの処理をします。

購入履歴検証の大まかな流れ

1.購入履歴のデータをサーバーにぶん投げる用に加工する
2.サーバーにぶん投げる
3.サーバーから返ってきた返答を処理する

という流れです。

購入履歴の検証するサーバーについて

サーバー側の検証では、FirebaseのCloudFunctionsという機能を使います。
Cloud Functions for Firebase

簡単に言えば、ある特定のURLにデータをつけてアクセスすると、CloudFunctionsに内に書いてあるコード処理が実行されるという仕組みです。

使い方は、下記の記事内にあるYoutubeがめっちゃ分かりやすいです。
はじめに: 最初の関数の記述、テスト、デプロイ  |  Firebase

他でもすでにFirebaseの設定を済ませているので、下記をチェックしてFirebaseの設定などは済ませておいてください。
Flutter アプリに Firebase を追加する

サーバー内の各種設定およびデータの加工

教科書通りに進めていけば問題ありません。

CloudFunctions処理の微調整

教科書には丁寧に記載されているので、そのまま流用せてもらえれば問題ありません。

私の場合、CloudFunctionsのHowTo動画を見てからやったので、CloudFunctions内で動作させる言語は「TypeScript」を選択しています。(動画の先生が一押しするから)

そのためか、CloudFunction内の処理でうまく行かないところがありました。データを受け取った後に、値を取り出していくのですが、json形式ではないためエラーになってしまいました。
そこで一度json形式に再フォーマットをしました。

//サーバー内の処理です。Flutter側ではありません。
//変更前
  const body = req.body;

//変更後
  const body = JSON.parse(req.body);

あとは特に問題はありません。
変更した所は、今回サブスクの更新のみなので、iOSは最新のレシートを取得すればいいので、下記のように変更しています。

//サーバー内の処理です。Flutter側ではありません。
//iOS側の処理
//変更前
   const receiptCollections: Array<object> = result['receipt']['in_app'];
  if (result['latest_receipt_info']) {
    // `latest_receipt_info`は定期購読タイプのアイテムを購入したことがある場合のみ存在しています。
    // 送信された`receipt-data`に紐づくAppleアカウントから行われた、このアプリに関する全てのアイテムの購入履歴が格納されています。
    // 定期購読タイプのアイテムを1度でも購入したことがある場合、それ以外のアイテムの購入履歴もここに含まれる様になります。
    // ここでは`latest_receipt_info`が存在していた場合、`receipt.in_app`に含まれていた購入履歴とマージして返却しています。
    receiptCollections.concat(...result['latest_receipt_info']);
  }


//変更後
   var receiptCollections: Array<object> = result['latest_receipt_info'];
//最新のレシートだけ返却

iOSとアンドロイドの購入履歴の違い

ちょっとややこしいのが、OS毎の購入履歴の取り扱い方の違いです。

iOSの購入履歴の場合

サブスク購入があると、「latest_receipt_info」という最新のレシートを含んだ形で購入履歴を返してきます。。

iOSの購入履歴については下記がまさに最強の名がふさわしいこの記事を読んでおくとわかりやすいです。
https://qiita.com/Masataka-n/items/6f98a5a9fee7b28ccd1f

Androidの購入履歴の場合

こちらは最新の定期購入かを判断できているので、有効期限の判定をサーバー内で処理しています。

この記事を書いていて冷静に考えてみたら、iOS側もサーバーで処理できそうな感じがしてます笑

購入履歴の検証後

各OS毎にレシートを検証して、何かしらのエラーや検証失敗となった場合、catch(e)に捕捉されて、falseを返却、処理が終了されます。

購入履歴検証が正常に終了した場合、trueが返されます。

その間に何かしら処理をが必要であれば処理しておきましょう。

リリースしたアプリでは全ての処理が終わったタイミングで、有料機能を開放するようにしています。

iOSだけ最新の購入履歴の情報が返ってきます。
その購入履歴の中に、期限をエポックミリ秒という時間を数字の変換している値が返ってくるので、それと現在時刻を比較しています。

Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
//省略
//iOSの処理
        final decoded = json.decode(response.body);//サーバーから返ってきたデータ
        final transactions = decoded['transactions'];//必要なデータの取り出し
//expires_date_msが有効期限のエポックミリ秒
        int expires_date_ms =  await int.parse(transactions.last['expires_date_ms']);//int型に変更

//現在時刻を加工
        String  now =  await DateTime.now().toUtc().millisecondsSinceEpoch.toString();//現在時刻をエポックミリ秒に変換
        int now_ms = await int.parse(now);//int型に変換

//有効期限と現在時刻を比較
        if(expires_date_ms >= now_ms) { ///を過ぎたものはfalseに
          print("有効期限内です。");
         //有効だった場合の処理
        } else {
          print("有効期限が切れています。");
        //期限切れの処理
           return false;
        }

//省略
  return true; //一番最後に返してます。
}

有料機能の有効化

有料機能を有効化できると判断したら、どこかのタイミングで有効化の処理をいれることになります。
私は、「 _verifyPurchase」がtrueを返すタイミングで、有効化しています。

有効化した場合は、アプリを終了させても記録されているようにする必要があります。

DBに保存するという方法もありますが、私は下記のパッケージを使ってます。

shared_preferences

shared_preferences | Flutter Package
データベースではなく、設定情報などが消えずに保存できるパッケージです。

反対に有料機能を無効化する場合は、何度かプロセスを踏ませています。

というのは、テスト段階ではちょくちょくサーバーへの問い合わせが失敗することがありました。
そのため、安易に無効化処理を入れてしまうと、有効期限内なのに一時的に有料機能が無効化された状態になることがありました。

ユーザーのことを考えると、確実に通信が成功して有効期限が切れていると確証できる場合以外は、無効化処理をしないようにしています。

サブスク更新チェック

一連の処理の中の最初の方、「initStoreInfo();」内で購入履歴を問い合わせ、購入履歴の検証をしています。

  Future<void> initStoreInfo() async {
//省略
   await _connection.queryPastPurchases();
    if (purchaseResponse.error != null) {
      // エラー処理
    }
    final List<PurchaseDetails> verifiedPurchases = [];
    for (PurchaseDetails purchase in purchaseResponse.pastPurchases) 
   //購入履歴の検証
      if (await _verifyPurchase(purchase)) {
   //サンプルでは購入したものとして、購入ボタンがチェック表示に変わる
   //購入済リストとして追加
        verifiedPurchases.add(purchase);
      }
    }

//省略

}

基本的にサブスクの更新チェックは、この一連の流れを繰り返すことになります。確認するだけなら不要な処理も含まれるので、別メソッドで切り出して、チェックの際はそちらを使う様にしています。

ちなみに、iOSの場合、最新の購入履歴が返ってくるので、無駄な処理な気はしてますね。

購入の復元ボタン

各ストアで購入の復元ボタンが必要そう。
ただ復元も結局、上記で紹介した処理を走らせてあげることで実現します。
公式のサンプルだと、特に復元ボタンがなくても、ログインした段階で、initstateで購入履歴が勝手に読み込まれてます。

ぶっちゃけ、ボタンはいらない気がしますが、審査を通すためにとりあえずつけてます。

重要!!課金機能でハマったポイント

今回課金機能の鬼門、実装してハマったポイントがあります。Androidは特に問題はなく動作したのですが、iOSでうまくいきませんでした。

実機の課金テストで不具合

課金テストではSandBoxユーザーというテスト用アカウントを使って課金テストをします。しかし、以下の問題がありました。

  • 課金ボタンを押しても反応しない
  • 新しいSandBoxユーザーなのに課金履歴が存在する
  • 最新の購入履歴が取得できず、ランダムで過去の購入履歴が取得されてしまう。

原因はゾンビ トランザクションだと思われます。

結論から言います。

いろいろと調べた結果、上記の不具合は、トランザクション(一連の購入処理)が溜まっていくことが問題ではないかと考えています。

https://github.com/flutter/flutter/issues/32759#issuecomment-620947340

AppStoreの場合、トランザクションの完了処理を通知させてないと、未完了のトランザクションが溜まっていくようです。
さらに自動でクリアもされません。

購入が正常に完了した場合、

   if (purchaseDetails.pendingCompletePurchase) {
    await InAppPurchaseConnection.instance.completePurchase(purchaseDetails);
    }

この処理でトランザクションを終了させているようです。

しかし、エラーやその他分岐処理で終わった場合、トランザクションを完了させてません。

なので実装中に何度もエラーなんかを出しまくると、どんどんとゾンビ トランザクションが溜まっていくわけです。

上記の処理を呼べばいいのですが、処理したいタイミングで引数の(purchaseDetails)がない場合あるので、そんなに気軽によべない問題があります。

さらに最悪なのは、どうもAppStoreはアカウントだけでなく、端末でトランザクションを記録しているのか、紐づいているらしく、トランザクションが溜まりまくるとカオス化していきました。

一時的な解決方法は、新しくサブスクアイテムを作り直すことで、きれいになります。

ただし、根本的な解決には至らないので、トランザクションを削除するように処理を仕込んでいきます。。

 ゾンビ トランザクション退治。

下記のトランザクションのキャンセル処理を、initstate時、購入時、エラー時におまじない的に仕込むことにしました。

ログを見ていたところトランザクションをひっぱり出して処理をしてくれているようです。
ただ、読み出すトランザクションの量が一定でなかったり、イマイチAppStoreの仕様が分かりません。
ちょっとまだ謎が残りますが、とにかくトランザクションをためないことが重要。

私の場合、これに気づいた時点で1週間くらいどハマりしていて、トランザクションが膨大に溜まっていたようで退治しきれず、新しくサブスクアイテムを作り直しました。

それ以降は、正常に動作しています。

トランザクションのキャンセル処理は以下のように実装しています。

  cancelTransction() async{
    print("トランザクションのキャンセルを実行");

    if (Platform.isIOS) {
      var paymentWrapper = SKPaymentQueueWrapper();
      var transactions = await paymentWrapper.transactions();
      for (var i = 0; i < transactions.length; i++) {
        print('トランザクションの削除を開始');
        print("${transactions[i].transactionIdentifier}:${transactions[i].payment.productIdentifier}");
        await paymentWrapper.finishTransaction(transactions[i]);
      }
    }
  }

まとめ

いかがでしたか?

景気が悪くなり広告収益型のビジネスが稼ぐのが難しくなっています。
特にアプリ関連は、iOS14の登場でさらに悪化すると予想されます。

今後は無料で多くの人に使ってもらうよりも、本当に必要とする人に、有料でも使いたいと思えるアプリを提供することが重要となってくるのではないかと思っています。

だからこそ課金機能は、今回アプリを実装するうえでどうしても実装したい機能でした。
しかし、有料課金の仕組みの理解、実装、意味不明な不具合の影響で、2週間程度どハマりしてしまいました。

途中挫けそうになりましたが、ツイッターでプロ個人開発の@atagonあたかさんにアドバイスを頂いたり、助けていただきました。この場で改めてお礼を言いたいと思います。

ありがとうございました。

なんやかんやで、なんとか実装することができました。
とはいえ、まだまだ無駄な処理や、深く理解できていないところもあり、改良の予知があるなと、この記事を書いて実感しています。

初めて実装する場合、有料課金の概念が分かりづらかったりすると思います。
なので、まずどんな仕組みなのかをじっくり調べてから実装すると早いかもしれません。

もし、分からないことがあれば遠慮なくコメントに残してください。
わかることであれば、回答させていただきます。

あと、四苦八苦して作ったアプリも是非ともよろしくお願いします。アプリのアイデア出しに使ってみてください。

■AppStore
https://apps.apple.com/jp/app/id1517535550

■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja

■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/

あとツイッターもやってますので、ぜひチェックください。

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

【Swift】ナビゲーションバーの戻るボタンの処理をカスタマイズしたい

はじめに

ナビゲーションバーの戻るボタンをタップ時に、「ダイアログを表示 -> キャンセルの場合、遷移させない」という処理を実装したかったのですが、デフォルトで実装されている戻るボタン (backBarButtonItem) をカスタマイズする方法ではうまくいきませんでした。

ダメだった方法
SomeViewController.swift
// MARK: onTapBackButton は呼ばれない
let backButton = UIBarButtonItem(image: chevronLeftImage, style: .plain, target: self, action: #selector(onTapBackButton(_:)))
navigationItem.backBarButtonItem = backButton

そこでデフォルトの戻るボタンは使わず、新たにボタンを作成し leftBarButtonItem として配置するという方法で実装しました。

環境

  • Xcode 11.5
  • Swift 5.2.4

デフォルトの戻るボタンを置き換える

特定の ViewController のみボタンを置き換えた場合、デフォルトの戻るボタンと見た目が変わってしまうため、今回は全てのボタンを置き換えます。
すべての画面で読み込む親クラスとして CommonViewController を生成し実装していきます。

コード全体
CommonViewController.swift
class CommonViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        backButton()
    }

    private func backButton() {
        let currentViewController = String(describing: type(of: self))
        guard !currentViewController == "FirstViewController" else {
            return
        }

        let chevronLeftImage: UIImage? = UIImage(systemName: "chevron.left")
        let backButton = UIBarButtonItem(image: chevronLeftImage, style: .plain, target: self, action: #selector(onTapBackButton(_:)))
        navigationItem.leftBarButtonItem = backButton
    }

    @objc
    func onTapBackButton(_ sender: UIBarButtonItem) {
        navigationController?.popViewController(animated: true)
    }
}

最初の画面 (FirstViewController) では戻るボタンを非表示にします。

CommonViewController.swift
// 現在の ViewController 名を取得
let currentViewController = String(describing: type(of: self))
guard !currentViewController == "FirstViewController" else {
    return
}

新たにボタンを生成し leftBarButtonItem に登録します。
leftBarButtonItem が表示されている場合、デフォルトの戻るボタンは非表示になります。

CommonViewController.swift
let chevronLeftImage: UIImage? = UIImage(systemName: "chevron.left")
let backButton = UIBarButtonItem(image: chevronLeftImage, style: .plain, target: self, action: #selector(onTapBackButton(_:)))
navigationItem.leftBarButtonItem = backButton

タップ時に呼び出されるアクションに、前画面へ遷移する処理を記述します。

CommonViewController.swift
@objc
func onTapBackButton(_ sender: UIBarButtonItem) {
    navigationController?.popViewController(animated: true)
}

特定の ViewController で処理をカスタマイズする

処理をカスタマイズしたい ViewController で onTapBackButton を override します。

SomeViewController.swift
@objc
override func onTapBackButton(_ sender: UIBarButtonItem) {
    // TODO: ここに処理を記述
    navigationController?.popViewController(animated: true)
}

参考

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

[iOS]実機ビルドはできるのに、fastlaneでdeploygateに配信したアプリはクラッシュする時の対処法

割と丸一日程度時間溶かしたたのでメモ。

実機ビルドはできるのに、fastlaneでdeploygateに配信したアプリは起動時にクラッシュする。:frowning2::frowning2::frowning2:

クラッシュ前後のコミットで特に悪いことはしてないように見えるのになぜかクラッシュする。

FirebaseのCrashlytics のクラッシュログをみても環境変数jsonをDocodeするところの強制アンラップでこけていることはわかったがなぜこれが起きるかがわからなかった。

setting.swift
Fatal error: 'try!' expression unexpectedly raised an error: Swift.DecodingError.dataCorrupted(Swift.DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON.", underlyingError: Optional(Error Domain=NSCocoaErrorDomain Code=3840 "No value." UserInfo={NSDebugDescription=No value.}))): file /Users/vagrant/git/hogehoge.swift, line 283

チームメンバーの助けで解決。

結論: fastalneをupdateしたら治った。

bundle update fastlane

コミットが悪いわけでないのにクラッシュする時は、ライブラリだったり証明書だったりを疑うのが良さそうですね。

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

MapKitで国土地理院のタイルデータを表示する

iOSの MapKit は地図の上にデータをオーバレイすることができます。
一方、国土地理院では日本地図に関わる様々なデータを公開しているのですが、実は簡単に MKMapView へ表示することができちゃいます。

スクリーンショット 2020-07-09 11.17.48.png

ViewController.swift
import UIKit
import MapKit

class ViewController: UIViewController {

    @IBOutlet weak var mapView: MKMapView!

    // 国土地理院が提供する色別標高図のURL
    // ここを変えるだけで、様々な地図データを表示できる!
    private let tileOverlay = MKTileOverlay(urlTemplate: "https://cyberjapandata.gsi.go.jp/xyz/relief/{z}/{x}/{y}.png")

    override func viewDidLoad() {
        super.viewDidLoad()

        mapView.delegate = self
        mapView.addOverlay(tileOverlay, level: .aboveLabels)
    }
}

extension ViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
        return MKTileOverlayRenderer(overlay: overlay)
    }
}

透過率を付けて重ね合わせたい場合は MKOverlayRendereralpha 値を調整します。

    @IBAction func sliderDidChange(_ slider: UISlider) {
        if let renderer = mapView.renderer(for: tileOverlay) {
            renderer.alpha = CGFloat(slider.value)
        }
    }

治水地形分類図 などは昨今の水害への対応に何かに活用できるのではないかと。

スクリーンショット 2020-07-09 11.35.06.png

各データには利用規約がありますので、利用の際には注意を払っておくと良さそうです。

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

(今後も更新予定)たった数行のコードで作成できる iOS 14 の新たなフレームワークの機能

1、 App Store アプリの概要カードをフロート表示する

アプリ内で他のApp Storeアプリをおすすめしたい場合、5行のコードで簡単にアプリの概要カードを表示することができます。

image.png

if let scene = view.window?.windowScene {
    let config = SKOverlay.AppConfiguration(appIdentifier: "1494658162", position: .bottom) //App Store アプリのApple ID
    let overlay = SKOverlay(configuration: config)
    overlay.present(in: scene)
}

2、明日も更新予定

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

(明日も更新予定) たった数行のコードで作成できる iOS 14 の新たなフレームワークの機能

1、 App Store アプリの概要カードをフロート表示する

アプリ内で他のApp Storeアプリをおすすめしたい場合、5行のコードで簡単にアプリの概要カードを表示することができます。

image.png

if let scene = view.window?.windowScene {
    let config = SKOverlay.AppConfiguration(appIdentifier: "1494658162", position: .bottom) //App Store アプリのApple ID
    let overlay = SKOverlay(configuration: config)
    overlay.present(in: scene)
}

2、明日も更新予定

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

たった数行のコードで作成できる iOS 14 の新たなフレームワークの機能

  • App Store アプリの概要カードをフロート表示する
  • UIBarButtonItem にフローティングメニューを表示
  • インラインの日付ピッカーを表示
  • コンパクト日付ピッカーを表示する
  • カラーピッカーを表示する
  • ゲームセンター用のフローティングウィンドウを表示 (Game Center)

App Store アプリの概要カードをフロート表示する

アプリ内で他のApp Storeアプリをおすすめしたい場合、5行のコードで簡単にアプリの概要カードを表示することができます。

image.png

if let scene = view.window?.windowScene {
    let config = SKOverlay.AppConfiguration(appIdentifier: "1494658162", position: .bottom) //App Store アプリのApple ID
    let overlay = SKOverlay(configuration: config)
    overlay.present(in: scene)
}

UIBarButtonItem にフローティングメニューを表示

UIAlertController を表示する代わりに、フローティングメニューを表示できます

navBar.png

let addCat = UIAction(title: "猫を追加", image: UIImage(systemName: "plus")) { (action) in
    print("猫を追加")
}
let shareButton = UIAction(title: "リストを共有", image: UIImage(systemName: "paperplane")) { (action) in
    print("リストを共有")
}
let menu = UIMenu(title: "", children: [addCat, shareButton])
let menuBarItem = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), menu: menu)
navigationItem.rightBarButtonItem = menuBarItem

インラインの日付ピッカーを表示

こちらが古い日付ピッカーです
oldDatePicker

こちらが新しい日付ピッカーです

newDatePicker.png

コード内で日付ピッカーのスタイルを変更することもできます

@IBOutlet weak var datePicker: UIDatePicker!
datePicker.preferredDatePickerStyle = .inline

コンパクト日付ピッカーを表示する

コンパクト日付ピッカーは、デフォルトで日付と時刻のラベルのみを表示します。ユーザーはクリックすると日付/時刻の選択を編集できます。

image.png

@IBOutlet weak var datePicker: UIDatePicker!
datePicker.preferredDatePickerStyle = .compact

カラーピッカーを表示する

image.png

func showColorPicker(){
    let colorPicker = UIColorPickerViewController()
    colorPicker.delegate = self
    self.present(colorPicker, animated: true, completion: nil)
}

ここではユーザーが選んだ色が分かります。

extension ViewController: UIColorPickerViewControllerDelegate {

    func colorPickerViewControllerDidSelectColor(_ viewController: UIColorPickerViewController) {
        print("Selected color: \(viewController.selectedColor)")
    }

    func colorPickerViewControllerDidFinish(_ viewController: UIColorPickerViewController) {
        print("Color picker has been closed")
    }

}

ゲームセンター用のフローティングウィンドウを表示

あなたのアプリやゲームがすでにゲームセンター (Game Center) 機能を使用している場合は、プレイヤーがクリックして自分の達成状況を閲覧できるフローティングウィンドウを追加することができます。

image.png

if let window = view.window {
    GKAccessPoint.shared.isActive = true
    GKAccessPoint.shared.location = .bottomTrailing
    GKAccessPoint.shared.parentWindow = window
}

image.png

フローティングGame Centerウィンドウを表示するためには、プレイヤーを認証する必要があります:

GKLocalPlayer.local.authenticateHandler = { vc, error in
    if let authVC = vc {
        self.present(authVC, animated: true, completion: nil)
    }
}

また、App Store Connect 内でGame Center機能を有効化し、フローティングGame Centerウィンドウを表示する前に少なくとも1つのゲーム達成項目を追加する必要があります。

より多くの面白い iOS 機能を見つけ次第、この記事の更新を続けていきます。 アップデートについては、 (Qiita & Twitter) でフォローをお願いします。

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

iPhoneアプリが待機中のままダウンロードできない

私のiPhoneXでは、wifiルーターの変更などで新しい
ネットワークに接続した際、iPhoneアプリの
ダウンロードが全く進まないという現象が起きます。

app storeアプリを起動→右上の人のシルエット
一番下までスクロールして「サインアウト」
再びサインインするとダウンロードが進みました。

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

Carthageで使うXcode・Swiftのバージョンをコントロールする

最近複数の仕事をしていて、Xcodeのバージョンをいくつか使っているんですが
carthageでライブラリーアップデートしてSwiftバージョン違うって言われると辛いですよね
あれめちゃくちゃ長いんですよ
1日溶ける

本題

バージョンが合っていないとこういうエラーが出てきます

<unknown>:0: error: module compiled with Swift 5.2.4 cannot be imported by the Swift 5.1.2 compiler: /Users/XXXXX/ios/yyyyyy/Carthage/Build/iOS/Hoge.framework/Modules/Hoge.swiftmodule/x86_64-apple-ios-simulator.swiftmodule

現在使っているXcodeを確認する

xcode-select --print-path

変更する

sudo xcode-select --switch <path-to-beta-xcode>/Contents/Developer

参考
https://stackoverflow.com/questions/37867753/change-carthage-swift-version

Cocoapodsの場合

環境設定から変更できます

スクリーンショット 2020-07-09 0.32.59.png

これcarthageに反映されないんですね。

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