20210303のSwiftに関する記事は22件です。

Swift触ってみるpart2

定数と変数について

備忘録シリーズ
それぞれの意味合いなどについて書いていく

定数

  • let 定数名
  • 定数:一度入れてしまったら変更不可能
let intNumber:Int
intNumber=1
print(intNumber) //出力結果:1


下記 ↓ はエラーになる

let intNumber:Int
intNumber=1
intNumber=100 //定数を変更しようとしてエラーになる行
print(intNumber) //出力結果:1

変数

  • var 変数名
  • 変数:何度でも値を入れることが可能
var intNumber:Int
intNumber=1
print(intNumber) //出力結果:1


下記 ↓ はエラーにならない

var intNumber:Int
intNumber=1
intNumber=100 
print(intNumber) //出力結果:100

型推論

  • 型推論:宣言と同時に値を代入すると型宣言が省略できる
let number=0.08
var count=0
count=1
print(number,count)

型チェック

  • type(of:定数or変数)
  • 型推論によって設定された値が何の型か調べる事ができる
let number=0.08
print(type(of:number))//出力結果:Double

型変換(キャスト)

  • 型を変換してくれるもの
ダメなパターン
let kosu=5      // int 
let tanka=2000  // int
let tax=1.10    // Double
let price=tanka*kosu*(1+tax)  ⇦ int型が先に入っているのに最終的にDouble型になってしまうためエラーが起きる
print(price)    // Double

成功パターン
let kosu=5
let tanka=2000
let tax=1.10
let price=Double(tanka*kosu)*(1+tax) ⇦ intであるtanka*kosuをDoubleにしてしまう
print(price)  //出力結果:21000.0

数値をStringにキャストしてみる

let subject="理科"
let point=82
let result=subject+String(point)+"です"
print(result) //理科82です
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【SwiftUI】よく使うビューをテンプレートとして作る方法

プログラミングの基本的な考え方として、重複するコードは何度も書かず、テンプレートを作成して使い回すというものがあるかと思います。SwiftUIでも同様に、同じようなビューをテンプレートとして用意し、記述をより簡単にすることができます。

テンプレを作らず書くとどうなるか

次のような、ボタンが4つあるアプリを考えます。

スクリーンショット 2021-03-03 21.28.51.png

それぞれのボタンを押すと、それに対応したキャラクターが画面上部に表示される仕組みです。この機能を普通に書くとこのようになります。

ContentView.swift
import SwiftUI

let character = ["ルフィ", "ゾロ", "ナミ", "サンジ"]

struct ContentView: View {
    @State var id = 0

    var body: some View {
        VStack{
            Image(character[id])
                .resizable()
                .aspectRatio(contentMode: .fit)
            Text("Choose Your Character!!")
                .font(.title)
                .fontWeight(.bold)
            // ボタンを4つ並べる
            HStack{
                Button(action: {
                    id = 0
                }, label: {
                    Text("ルフィ")
                        .font(.title)
                        .foregroundColor(.white)
                        .frame(width: 130, height: 130)
                        .background(Color.red)
                        .cornerRadius(20)
                })
                .padding()
                Button(action: {
                    id = 1
                }, label: {
                    Text("ゾロ")
                        .font(.title)
                        .foregroundColor(.white)
                        .frame(width: 130, height: 130)
                        .background(Color.green)
                        .cornerRadius(20)
                })
                .padding()
            }
            HStack{
                Button(action: {
                    id = 2
                }, label: {
                    Text("ナミ")
                        .font(.title)
                        .foregroundColor(.white)
                        .frame(width: 130, height: 130)
                        .background(Color.orange)
                        .cornerRadius(20)
                })
                .padding()
                Button(action: {
                    id = 3
                }, label: {
                    Text("サンジ")
                        .font(.title)
                        .foregroundColor(.white)
                        .frame(width: 130, height: 130)
                        .background(Color.blue)
                        .cornerRadius(20)
                })
                .padding()
            }
        }
    }
}

長い。

コードをよく読むと、この部分が繰り返し使われていることがわかります。

重複しているコード
                Button(action: {
                    id = 0
                }, label: {
                    Text("ルフィ")
                        .font(.title)
                        .foregroundColor(.white)
                        .frame(width: 130, height: 130)
                        .background(Color.red)
                        .cornerRadius(20)
                })
                .padding()

ボタンに書かれている文字や色、機能によって少しコードに違いは出ますが、だいたいは同じことが書かれています。この部分をテンプレートとして準備すれば良さそうです。

ビューの基本的な書き方

ビューの書き方
struct hoge: View {

    // プロパティなど

    var body: some View {

        // ここにビューを書く

    }
}

まず、ビューの基本的な書き方は上のようになります。ここで気づく方もいると思いますが、これはSwiftUIで新しくファイルを作ったときに書かれる内容とまったく同じものです。このbodyの中にビューを書くことでテンプレートを作ることができます。では、実際に書いてみましょう。

テンプレートを作って書き換えよう

テンプレート
struct CharacterButton: View {
    // 違う部分はプロパティにしてそれぞれ変えられるようにする
    @Binding var id: Int
    let number: Int
    let buttonColor: Color

    var body: some View {
        // 重複しているコードを書き出す
        Button(action: {
            id = number
        }, label: {
            Text(character[number])
                .font(.title)
                .foregroundColor(.white)
                .frame(width: 130, height: 130)
                .background(buttonColor)
                .cornerRadius(20)
        })
        .padding()
    }
}

このようになります。重複しているコードをそのまま書いてしまうと違いが出せないため、違う部分はプロパティにし、インスタンス生成時に情報を書けるようにします。

このテンプレートを呼び出すときは次のように書きます。

テンプレートの呼び出し
CharacterButton(id: , number: , buttonColor: )

引数にそれぞれのボタンが持つ特性を書き込めば、インスタンスを生成できます。
このテンプレートを使用し、ContentView.swiftを書き換えたいと思います。

ContentView.swift
import SwiftUI

let character = ["ルフィ", "ゾロ", "ナミ", "サンジ"]

struct ContentView: View {
    @State var id = 0

    var body: some View {
        VStack{
            Image(character[id])
                .resizable()
                .aspectRatio(contentMode: .fit)
            Text("Choose Your Character!!")
                .font(.title)
                .fontWeight(.bold)
            // ボタンを4つ並べる
            HStack{
                CharacterButton(id: $id, number: 0, buttonColor: Color.red)
                CharacterButton(id: $id, number: 1, buttonColor: Color.green)
            }
            HStack{
                CharacterButton(id: $id, number: 2, buttonColor: Color.orange)
                CharacterButton(id: $id, number: 3, buttonColor: Color.blue)
            }
        }
    }
}

// テンプレート
struct CharacterButton: View {
    @Binding var id: Int
    let number: Int
    let buttonColor: Color

    var body: some View {
        Button(action: {
            id = number
        }, label: {
            Text(character[number])
                .font(.title)
                .foregroundColor(.white)
                .frame(width: 130, height: 130)
                .background(buttonColor)
                .cornerRadius(20)
        })
        .padding()
    }
}

スッキリして読みやすくなりましたね。それだけでなく、もしボタンのUIを一括に変更したい、ということがあった場合、テンプレートひとつをいじれば良いのでメンテナンス性も向上しています。

まとめ

スッキリとした読みやすいコードが書けると、アップデートなどで後々自分が楽になります。テンプレートを駆使して読みやすいコードが書けるよう、私も考えながら書きたいと思います。

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

FireAuthで確認メール送信方

概要

普段アプリを作る時にそうゆえば確認メール送信したことないなと思い調べました。
また、ついでに RxSwiftの勉強も平行でしていたのでRxSwiftで書きました。
RxSwiftの方の解説はなしで行きます。

ではさっそくコードを書いていきましょう。

ViewModel

protocol ViewPresentable {
    typealias Input = (
        emailText: Driver<String>,
        passwordText: Driver<String>,
        tappedButton: Driver<()>
    )
    typealias Output = (
        isValid: Driver<Bool>, ()
    )

    var input: ViewPresentable.Input { get }
    var output: ViewPresentable.Output { get }
}

class ViewModel: ViewPresentable {
    var input: ViewPresentable.Input
    var output: ViewPresentable.Output

    private var emailBehavior = BehaviorRelay<String>(value: "")
    private var passwordBehavior = BehaviorRelay<String>(value: "")

    private let disposeBag = DisposeBag()

    init(input: ViewPresentable.Input) {
        self.input = input
        self.output = ViewModel.output(input: self.input)

        input.emailText
            .drive(emailBehavior)
            .disposed(by: disposeBag)
        input.passwordText
            .drive(passwordBehavior)
            .disposed(by: disposeBag)

        process()
    }
}

private extension  ViewModel {
    static func output(input: ViewPresentable.Input) -> ViewPresentable.Output {
        let emailDriver = input.emailText
        let passwordDriver = input.passwordText

        let isValid = Driver.combineLatest(emailDriver, passwordDriver) { (email, password) -> Bool in
            return !email.isEmpty && !password.isEmpty
        }.asDriver(onErrorJustReturn: false)

        return (
            isValid: isValid, ()
        )
    }

    func process() {
        self.input
            .tappedButton.drive(onNext: {
                let email = self.emailBehavior.value
                let password = self.passwordBehavior.value
                AuthManager.shared.createUser(email: email, password: password)
            }).disposed(by: disposeBag)
    }
}

AuthManager

class AuthManager {
    static let shared = AuthManager()

    private let auth = Auth.auth()
}

extension AuthManager {
    func createUser(email: String, password: String) {

        auth.createUser(withEmail: email, password: password) { (result, error) in
            guard let user = result?.user, error == nil else {
                print(error!)
                return
            }

            user.sendEmailVerification { (error) in
                if let error = error {
                    print(error)
                }
            }
        }
    }
}

今回の記事の肝は

user.sendEmailVerification

の部分です。
これで確認メールを送信できます。
ただ、デフォルトでは日本語じゃないのでFirebaseコンソールで
Authenticationを開いてTempletesのタブを開きます。
そこの一番下にテンプレート言語というのがあるのでペンマークを押すと言語を変更できます。
また、メールのメッセージ内容も変更できるのでいじってみてください。

ViewController

class ViewController: UIViewController {

    @IBOutlet weak var emailField: UITextField!
    @IBOutlet weak var passwordField: UITextField!
    @IBOutlet weak var subscribeButton: UIButton!

    private let disposeBag = DisposeBag()
    private lazy var viewModel = ViewModel(input: (emailText: emailField.rx.text.orEmpty.asDriver(),
                                                   passwordText: passwordField.rx.text.orEmpty.asDriver(),
                                                   tappedButton: subscribeButton.rx.tap.asDriver()))

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

    private func setupBind() {
        viewModel.output.isValid
            .drive(subscribeButton.rx.isEnabled)
            .disposed(by: disposeBag)
    }

}

こんな感じです。

実行してみるとメールが届きました。
スクリーンショット 2021-03-03 19.01.38.png

まとめ

今回解説はかなり少ないですが確認メールを送信するだけなので難易度は高くないと思います。

今まで実装してこなかったのですが確認メールは必須ですよね。

ご指摘等ございましたらコメントの方よろしくお願いいたします。

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

【Swift】Firebase UIをカスタマイズしてお洒落なサインイン画面を作る

環境

  • Xcode Version 12.3 (12C33)
  • FirebaseUI (10.0.2)
  • iOS 13 以上

目標

  • Firebase UI を用いて楽にサインイン処理を実装したい
  • デフォルトのデザインがあまりにも寂しすぎるためきれいにしたい
デフォルトのFirebase UI によるサインイン画面
Simulator Screen Shot - iPhone SE (iOS 14.2.2nd generation) - 2021-03-03 at 18.16.42.png

直したいポイント

  1. フルスクリーン表示にしたい
  2. "Cancel" を非表示にしたい
  3. "Welcome" は "サインイン" にしたい
  4. そもそも NavigationBar 自体非表示にしたい
  5. サービス選択ボタンに丸みをつけたい
  6. 背景色を変更し画像を追加したい

Firebase UI でデフォルトのサインイン画面を表示する

  • AppDelegate で Firebase と接続
  • Googleアカウントによるログインを実装する場合には追加でおまじないが必要
AppDelegate.swift

import Firebase
import GoogleSignIn

import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {


    // MARK: - UIApplication

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // Firebase に接続する
        FirebaseApp.configure()
    }

    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {

        guard let sourceApplication = options[.sourceApplication] as? String? else { fatalError() }

        if FUIAuth.defaultAuthUI()?.handleOpen(url, sourceApplication: sourceApplication) ?? false {

            return true
        }

        return false
    }

    // 以下略
}
  • 表示元のViewController で Firebase の設定を行う
PresentingViewController.swft
import FirebaseUI

import UIKit


// MARK: - PresentingViewController

class PresentingViewController: UIViewController {


    // MARK: - UIViewController

    override func viewDidLoad() {

        super.viewDidLoad()

        self.setupFirebaseUI()
    }


    // MARK: - Private

    private func setupFirebaseUI() {

        guard let authUI = FUIAuth.defaultAuthUI() else { fatalError() }

        authUI.providers = [FUIGoogleAuth(authUI: authUI)]       
        authUI.delegate = self
    }
}


// MARK: - FUIAuthDelegate

extension PresentingViewController: FUIAuthDelegate {

    func authUI(_ authUI: FUIAuth, didSignInWith authDataResult: AuthDataResult?, error: Error?) {

        if let result = authDataResult {

            // ログイン成功処理
        }
        else {

            // ログイン失敗処理
        }
    }
}
  • Modal 表示する
    • Push 表示はできないため注意
PresentingViewController.swift
    @IBAction private dynamic signInButtonTapped(_ sender: Any?) {

        let viewController = authUI.authViewController()
        self.present(viewController, animated: true, completion: nil)
    }

カスタムしたクラスを割り当てる

  • FUIAuthPickerViewController を継承したクラスを作成することでカスタム可能となる
CustomAuthPickerViewController.swift
import FirebaseUI

import UIKit


// MARK: - CustomAuthPickerViewController

class CustomAuthPickerViewController: FUIAuthPickerViewController {
}
  • カスタムしたクラスを割り当てるには FUIAuthDelegate で指定する
PresentingViewController.swift
extension DataSyncViewController: FUIAuthDelegate {

    // 中略

    func authPickerViewController(forAuthUI authUI: FUIAuth) -> FUIAuthPickerViewController {

        return CustomAuthPickerViewController(authUI: authUI)
    }
}

フルスクリーン表示したい

  • 通常のViewController と同じく modalPresentationStyle をいじる
PresentingViewController.swift
        let viewController = authUI.authViewController()
        viewController.modalPresentationStyle = .fullScreen
        self.present(viewController, animated: true, completion: nil)

キャンセルボタンを非表示にしたい

  • CustomAuthPickerViewControllerauthUI.shouldCancelButton を設定
CustomAuthPickerViewController.swift
class CustomAuthPickerViewController: FUIAuthPickerViewController {


    // MARK: - UIViewController

    override func viewDidLoad() {

        super.viewDidLoad()

        self.setupUI()
    }



     // MARK: - Private

     private func setupUI() {

         // キャンセルボタンが消える
         self.authUI.shouldCancelButton = true
     }
}

"Welcome" は "サインイン" にしたい / そもそも NavigationBar は非表示にしたい

  • NavigationBar 関連のUI調整は viewWillAppear のタイミングで行う必要がある
    • viewDidLoad() で設定しても Firebase UI に設定を上書きされる
CustomAuthPickerViewController.swift
    override func viewWillAppear(_ animated: Bool) {

        super.viewWillAppear(animated)

        // タイトルを "サインイン" に変更
        self.title = "サインイン"
        // NavigationBar を非表示
        self.navigationController?.navigationBar.isHidden = true
    }

サービス選択ボタンに丸みをつけたい

  • ヒエラルキーは以下のようになっている
    • self.view > ScrollView > ContentView > Button
  • ScrollView と ContentView には .subviews[0] でアクセス可能
CustomAuthPickerViewController.swift
    private func setupUI() {

        // 各ボタンの UI を変更する
        self.view.subviews[0].subviews[0].subviews[0].subviews.forEach { (view: UIView) in

            if let button = view as? UIButton {

                button.layer.cornerRadius = 20.0
                button.layer.masksToBounds = true
            }
        }
    }

背景を青色にしたい/背景に画像を表示したい

  • ScrollView、ContentView のそれぞれの背景色を .clear にすることで self.view.backgroundColor の設定が表示されるようになる
CustomAuthPickerViewController.swift
    private func setupUI() {

        let scrollView = self.view.subviews[0]
        scrollView.backgroundColor = .clear
        let contentView = scrollView.subviews[0]
        contentView.backgroundColor = .clear

        // 背景色を変更
        self.view.backgroundColor = .blue
    }
  • 背景イメージを表示するには上記 + self.view.addSubviewUIImageView を追加する
  • 背景イメージを背面に移動させないとボタンが隠れるため注意
CustomAuthPickerViewController.swift
    private func setupUI() {

        let scrollView = self.view.subviews[0]
        scrollView.backgroundColor = .clear
        let contentView = scrollView.subviews[0]
        contentView.backgroundColor = .clear

        // 背景にイメージを追加
        let imageViewframe = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height:UIScreen.main.bounds.height)
        let imageView = UIImageView(frame: imageViewFrame)
        imageView.image = UIImage(named: "myImage")
        imageView.contentMode = .scaleAspectFill

        self.view.addSubview(imageView)
        self.view.sendSubviewToBack(imageView)
    }

結果

  • (お洒落かどうかはともかく)サインイン画面をカスタマイズすることができた
カスタム前 カスタム後
Simulator Screen Shot - iPhone SE (iOS 14.2.2nd generation) - 2021-03-03 at 18.16.42.png Simulator Screen Shot - iPhone SE (iOS 14.2.2nd generation) - 2021-03-03 at 17.45.51.png

改善点

  • Storyboard を利用してカスタムする方法を検証
    • より直感的にUIを変更したい
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

jazzyのエラーが解決できない人向けの記事( could not successfully run `xcodebuild`. Please check the build arguments. Failed to generate documentation)

検索でヒットする記事を見ても解決できなかったので備忘録として、そして同じエラーを繰り返さないために記事を残しておきます。
※間違っていたらご指摘お願い致します。

エラー内容

image.png
スクリーンショット 2021-02-28 11.50.59

エラー原因

色々試した結果アプリがビルド可能な状態でないと、jazzyもドキュメント化できないようです。

参考サイト

・メインで役立った記事 https://dev.classmethod.jp/articles/generate-documentation-using-jazzy/
・ドキュメントの書き方 https://qiita.com/Qiita/items/c686397e4a0f4f11683d
・jazzyのインストール方法 https://qiita.com/satoshi-baba-0823/items/826d38bc72230e4b5f6a
・見ておくと役に立つかもしれない記事 https://qiita.com/uhooi/items/d900c2de03e9d9f39b95

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

Swift触ってみるpart1

Swiftよく理解していないマンなので勉強

備忘録として書いていく

print : 値を出力させる

単体で値を表示させる

スクリーンショット 2021-03-03 16.37.55.png

複数の値を表示させる

スクリーンショット 2021-03-03 16.46.11.png

separator : 値の区切り文字を指定して出力させる

スクリーンショット 2021-03-03 16.49.35.png

terminator : 出力行の最後の文字を指定する

スクリーンショット 2021-03-03 17.10.58.png

特殊なキーワード

キーワード 内容
#file 現在のファイル名
#line 現在の行数
#column print ~ #までの utf8のバイト数  ひらがな3byte 漢字4byte
#function 現在の関数名

スクリーンショット 2021-03-03 17.51.35.png

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

[翻訳] 初めてのCore Bluetooth

訳者まえがき

この記事は通信SDKを開発する企業Dittoのサイトに寄稿された、Tim Oliver氏による記事です。
英語の記事はこちらでご覧いただけます。


初めに

スマートフォンとタブレットは世の中を大きく変えてきました。2007年に発売されたiPhoneから様々なデバイスが直感的なタッチインターフェースとネットワーク技術を持つようになりました。

iOSの開発者として、私は自分自身がネットワークに精通しているとは思っていません。最近の全てのアプリには当然のようにデータのエンコードやデコードを処理するREST APIのようなものが存在します。

しかし私がこれまで触れてこなかったスマートフォンのネットワーク技術に、Bluetoothがあります。
ワイヤレスイヤホンを着けながらこの記事を書いている時、手首にApple Watchを着けている時、Nintendo Switchで『どうぶつの森』をワイヤレスProコントローラーでプレイしている時、Bluetoothは当然のものの様に感じてしまいます。
意識せずとも動作している魔法のブラックボックスです。

これまでのiOSエンジニアとしてのキャリアの中で、Appleが提供するBluetooth API使うプロジェクトに関わったことはありませんでしたし、サイドプロジェクトでも必要性を感じたことはありませんでした。
なので友人のMax(訳注: Ditto社の共同創業者)がCore Bluetoothの動作を示すためのアプリを作って、そのことについての記事を書いてくれないかと依頼してきた時、このチャンスに飛びつきました。

この記事は私と同じような経験をしてきた人に向けて書きました。Appleのプラットフォーム向けの開発をしたことがあったとしてもCore Bluetooth APIを学ぼうと思わなかった人もいると思います。
Core Bluetoothの parent/child アーキテクチャ、簡単なデータを送受信する方法、そして遭遇したいくつかの落とし穴について書いていきます。

Core Bluetooth とは

Core BluetoothはAppleのパブリックフレームワークであり、サードパーティ製アプリがiOSやiPadOS上のアプリにBluetooth機能を組み込むための唯一の公式な方法です。

元々、Core Bluetoothは「Bluetooth Low Energy (BLE)を抽象化したもの」でした(BLEはBluetoothクラシックとは違い低消費電力デバイスでも動作するような省電力な通信をする技術です)。
つまりこれまでのCore Bluetoothは、心拍計やIoTデバイスのような低消費電力で定期的に小さなデータをブロードキャストするような用途に使われていました。

一方でゲームコントローラやワイヤレスヘッドフォンなどのようなBluetoothクラシックを利用しているデバイス(より高出力で常にデータをストリーミングしている)はサードパーティ製のアプリからアクセスすることができませんでした。

しかしiOS13から、AppleはCore BluetoothでもBluetoothクラシックをカバーできる様に拡張しました。
パブリックAPIはまだ変更されていませんが、より様々なデバイスが利用できる様になったということです。
Core Bluetoothを学ぶにはとても良いタイミングだと思います。

Core Bluetoothの基本概念

Core Bluetoothを利用するには、関連する専門用語とその関係性に慣れておく必要があります。

セントラル(Central)とペリフェラル(Peripheral)

BLE は、非常に伝統的なサーバー/クライアント型のモデルで動作します。つまり一つのデバイスが情報を含むサーバーとして機能し、別のデバイスはこの情報をクエリしてローカルで処理/表示するクライアントとして機能します。

いくつかのデバイスが自分のデータをブロードキャストします。これがペリフェラルです。
他のデバイスはそれをスキャンして接続します。これがセントラルです。
接続された後は通常は、ペリフェラルがサーバー、セントラルがクライアントとして動作します。
これがCore Bluetoothの動作です。

一般的な例として、Bluetoothヘッドホンをスマートフォンにペアリングする場合、スマートフォンがセントラルとなり、ヘッドホンがペリフェラルとなります。

image

サービス(Service)

もちろんBLEデバイスの種類によって、どのような機能を持っているかが決まります。
例えば心拍計は心拍数を記録し、温度計は温度を計測します。

ほとんどのアプリはデバイスの特有の機能をサポートするように構築されると思います。
例えば健康をトラッキングするアプリは温度計のような健康に関係のないデバイスとの接続には興味がないでしょう。
ペリフェラル機器の特有の機能を、Bluetoothでは「サービス」と呼んで扱います。

ペリフェラルは、自身のサービスを定義したアドバタイズメントパケットをブロードキャストすることで、セントラルに見つけられるようにします。
セントラルはスキャン中に、探していたサービスをサポートするペリフェラルからのパケットを見つけると、接続を開始します。
最初はメインのサービスのみ送信されますが、一度接続を構築した後は、セントラルはその他のペリフェラルのサービスも照会することができます。

センターがペリフェラルとの互換性を確認するには、ペリフェラルがサポートするIDを知る必要があります。
特殊なアプリやペリフェラル機器の場合、お互いのUUIDを利用してサービスを定義しても良いでしょう。

しかし一般的には、ペリフェラルがBluetoothの世界的な基準に則るのが自然だと思います。
例えば血圧を記録するデバイスは、どの機器で計測されたデータであってもそのデータを処理することが出来るかもしれません。
このような、特定のユースケースを利用したいセントラルとペリフェラルの間で利用できる、標準化されたサービスIDのデータベースが存在します。

キャラクタリスティック(Characteristic)

一つのサービスには、複数の様々なデータが含まれる場合があります。
例えば、心拍計には心拍数の情報とセンサーの配置の情報が含まれます。

この様にサービスは、様々なデータを計測したり関連した機能を実行するなど、複数の特徴(キャラクタリスティック)を含むことがあります。
これらのデータはペリフェラルから送られることもあれば、ペリフェラルに送り返されることもあります。
セントラルは一つのキャラクタリスティックをクエリしたり、キャラクタリスティックが更新するたびに呼び出されるオブザーバーを登録したりすることができます。

Bluetoothデバイスが自身の機能をサービスやキャラクタリスティックとして提供することを、GATT (Generic Attribute Profile) と呼びます。

Core Bluetoothのコンセプトのまとめ

ここまででCore Bluetoothの基本的な部分が理解できていることを願っています。

親デバイスは「センター」と呼ばれ、「ペリフェラル」と呼ばれる子デバイスに接続します。
ペリフェラルは「サービス」として機能を管理し、サービスは「キャラクタリスティック」として機能を管理します。

実践

Core Bluetoothの基本的な概念を説明したところで、早速実践してみましょう。
2つのデバイスを接続する流れを見せるために、Core Bluetoothを中心としたサンプルアプリを作ってみました。
このアプリは簡易的なチャットアプリで、お互いを接続し、メッセージのデータを送受信します。

このアプリはセントラルとしてスキャンする方法と、ペリフェラルとしてアドバタイズする方法を紹介します。
接続されると、アプリはその後、1つのパイプラインを介して上流と下流の両方にメッセージを送信することができます。

まず初めに

まず何よりも最初に、NSBluetoothAlwaysUsageDescriptionキーをアプリのInfo.plistに追加して、Bluetoothを使用する理由を記述する必要があります。
このキーが存在しない場合、アプリは提出時にApp Storeに拒否されるだけでなく、アプリ自体が例外をスローしてCore Bluetooth APIを呼び出そうとします。

これはAppleのセキュリティ要件であり、すべてのアプリはBluetoothを有効にする前にユーザーから明示的な許可を得なければならないからです。
今回は、チャットサービスを有効にするためにBluetoothが必要であることを説明します。

他のデバイスとメッセージをやりとりするためにBluetoothにアクセスします。

これでCore Bluetoothを使い始めることができます。
Swiftでは、このフレームワークを利用する全てのソースファイルに以下のようなインポート文を記述する必要があります。

import CoreBluetooth

セントラルとしてスキャンする

Bluetooth 接続でセントラルの役割を果たす iOS デバイスは、CBCentralManager と呼ばれるオブジェクトで表現されます。

まず、新しいインスタンスを作成してみましょう。

let centralManager = CBCentralManager(delegate: self, queue: nil)

このように、オブジェクトはインスタンス化時にデリゲートとして指定されなければなりません。
このオブジェクトは CBCentralManagerDelegate に準拠していなければなりません。
セントラルマネージャをインスタンス化すると、直ちにBluetoothに必要なアクティビティが開始されます。

この時点では、まだスキャンを開始することはできません。
Bluetoothが「電源が入った(powered on)」状態になるまでかなりの時間がかかります。
そのため、最初のデリゲートコールバックを待つ必要があります。

func centralManagerDidUpdateState(_ central: CBCentralManager) {
    guard central.state == .poweredOn else { return }
    // ペリフェラルのスキャンを開始する
}

centralManagerDidUpdateState は、システム上のBluetoothの状態が変化するたびに呼ばれます。
Bluetoothがリセットされた時や、アクセスが許可されていない場合にも呼ばれます。

本番環境では全ての状態を適切に処理する必要がありますが、ここではBluetoothがオンになった状態(powered on)のみ検知するようにします。
その状態になればスキャンを開始することができます。

ペリフェラルのスキャンはとても簡単です。
scanForPeripherals を呼び出して、利用したいサービスを指定するだけです。

let service = CBUUID(string: "9f37e282-60b6-42b1-a02f-7341da5e2eba")
centralManager.scanForPeripherals(withServices: [service], options: nil)

上述したように、サービスは一意の識別子を持っているので、ペリフェラルやセンターはそれにマッチする可能性があります。
Core Bluetoothでは、これらの識別子は CBUUID オブジェクトを介して処理されます。ここではシンプルな文字列を使用します。

このチュートリアルでは、オンラインのUUIDジェネレーターから生成されたUUID文字列値を使用しています。
値はグローバルに一意である必要がありますが、ペリフェラル側とセントラル側の両方から認識可能です。

この時点では、同じサービスIDを持つペリフェラルをスキャンしています。
好きなタイミングで centralManager.isScanning を呼ぶことで、スキャンしているかどうかを確認することができます。

ペリフェラルとしてアドバタイズする

セントラルがスキャンしているので、スキャンしているサービスと同じものをアドバタイズする別のペリフェラルのデバイスが必要です。

セントラルが CBCentralManager を介して管理されるのと同様に、ペリフェラルは CBPeripheralManager のインスタンスによって管理されます。

let peripheralManager = CBPeripheralManager(delegate: self, queue: nil)

セントラルマネージャと全く同じように、ペリフェラルマネージャも、作成時にデリゲートを必要とします。(今回は CBPeripheralManagerDelegate
そしてデバイス上のBluetoothの状態が「powered on」になるのを待つ必要があります。

func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
  guard peripheral.state == .poweredOn else { return }
  // Start advertising this device as a peripheral
}

ペリフェラルのBluetoothの状態がオンになると、ペリフェラルは自身のアドバタイズを始めることができます。

let characteristicID = CBUUID(string: "890aa912-c414-440d-88a2-c7f66179589b")

// キャラクタリスティックを作成し、設定する
let characteristic = CBMutableCharacteristic(type: characteristicID,
                          properties: [.write, .notify],
                          value: nil,
                          permissions: .writeable)

// サービスを作成し、そこにキャラクタリスティックを追加する
let serviceID = CBUUID(string: "9f37e282-60b6-42b1-a02f-7341da5e2eba")
let service = CBMutableService(type: serviceID, primary: true)
service.characteristics = [characteristic]

// このサービスをペリフェラルマネージャに登録する
peripheralManager.add(service)

// サービスIDによってサービスを指定し、アドバタイズを開始する
peripheralManager.startAdvertising(
            [CBAdvertisementDataServiceUUIDsKey: [service],
             CBAdvertisementDataLocalNameKey: "Device Information"])

複雑に見えますが、一つずつ見ればそれほど複雑ではありません。

  1. キャラクタリスティックを作成し、セントラルが期待する標準化されたUUIDを設定します。そしてそのキャラクタリスティックをwriteable(書き込み可能)にして、セントラルがデータを送り返せるようにする必要があります。
  2. 標準化されたサービスUUIDを持つサービスオブジェクトを生成し、タイプをプライマリに設定して、このペリフェラルの「メイン」サービスとしてアドバタイズされるようにします。
  3. 同じサービスIDでペリフェラルをアドバタイズします。CBAdvertisementDataLocalNameKey は通常、ペリフェラルのデバイス名を保持しますが、センターが使う追加データ(温度計の現在の温度など)を保持するようにすることもできます。

セントラルからペリフェラルを識別する

ここまでで、片方のデバイスがスキャンし、同じサービスIDでもう片方のデバイスがアドバタイズしているので、お互いを見つけられるはずです。

セントラル側では、ペリフェラルが見つかると以下のデリゲートコールバックが呼ばれます。

func centralManager(_ centralManager: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {

  // `advertisementData` をチェックして、これが正しいデバイスかどうかを判断する

  // このデバイスへの接続を試みる
  centralManager.connect(peripheral, options: nil)

  // ペリフェラルを保持する
  self.peripheral = peripheral
}

didDiscoverPeripheral はペリフェラルに関する多くの情報を提供します。
advertisementData には CBAdvertisementDataServiceUUIDsKey に定義された全てのサービスUUIDに加えて、デバイスの名前やメーカー名などのペリフェラルに関する情報が含まれています。
(他にももっとあるかもしれません)

必要であれば advertisementData[CBAdvertisementDataServiceUUIDsKey] を使って、このペリフェラルがセントラルの要求するサービスをサポートするかをチェックすることもできます。
またRSSI(Received Signal Strength Indicator) は、ペリフェラルとの距離を判定するのに役立ちます。
動作に近い距離であることが要求されることがあり、この値はその監視に使用することができます。

もしこのペリフェラルが接続したいものであれば、 centralManager.connect() を呼び接続を開始することができます。

このペリフェラルオブジェクトにデリゲートの外でアクセスする方法が無いため、クラス内のプロパティに保持しておくと良いと思います。

ペリフェラルへの接続

ペリフェラルを検出して centralManager.connect() を呼び出すと、セントラルはそのペリフェラルに接続しようとします。
接続すると、以下のデリゲートメソッドが呼び出されます。

func centralManager(_ centralManager: CBCentralManager,
                        didConnect peripheral: CBPeripheral) {
  // 接続されたため、スキャンを停止する
  centralManager.stopScan()

  // ペリフェラルのデリゲートを設定する
  peripheral.delegate = self

  // コミュニケーションに利用するチャットのキャラクタリスティックをdiscoverする
  let service = CBUUID(string: "9f37e282-60b6-42b1-a02f-7341da5e2eba")
  peripheral.discoverServices([service])
}

この時点でペリフェラルを扱えるようになり、セントラルマネージャではなく、ペリフェラルオブジェクトを直接操作します。
このペリフェラルオブジェクトは CBPeripheral という型で、CBPeripheralManager とは全く別のものです。

そのためまず最初に行うことは、このペリフェラルから直接イベントを受信できるように、自分自身をこのペリフェラルのデリゲートとして割り当てることです(CBPeripheralDelegateに準拠)。
次にペリフェラルの discoverServices を呼び出すことで、そのペリフェラルがサポートしているサービスを discover し、必要なサービスのキャラクタリクティックにアクセスすることができます。

ペリフェラルのサービス内のキャラクタリスティックを確認する

自分自身をペリフェラルのデリゲートに設定し、そのサービスを確認するためのリクエストを行うと、 CBPeripheralDelegate の以下のメソッドが呼び出されます。

func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
  // もしエラーが起きた場合、接続を遮断して最初からやり直せるようにする
  if let error = error {
    print("サービスが見つかりません: \(error.localizedDescription)")
    cleanUp()
    return
  }

  // 必要なキャラクタリスティックを指定する
  let characteristic = CBUUID("890aa912-c414-440d-88a2-c7f66179589b")

  // サービスが複数ある場合があるため、ループして必要なキャラクタリスティックをdiscoverする
  peripheral.services?.forEach { service in
    peripheral.discoverCharacteristics([characteristic], for: service)
  }
}

この時点でエラーが発生した場合は、接続を処理して終了させる必要があります。
そうでなければ、peripheral オブジェクトには、サポートしているすべてのサービスが登録されることになります。

サービスが CBUUID オブジェクトを介して識別されるのと同じように、キャラクタリスティックも識別されます。
キャラクタリスティックを購読してそのデータを読み込む前に、サービスの中の一つとして discover する必要があります。

ペリフェラルは複数のサービスを持てるため、ループによって必要なキャラクタリスティックを見つける必要があります。
peripheral.services を検索し、特定のキャラクタリスティックIDを見つけ出します。

キャラクタリスティックを購読する

チャットアプリでは、ペリフェラルからのデータの受動的なストリームには興味がなく、データが来たらすぐに通知されるようにしたいです。
そのため、キャラクタリスティックが更新されればすぐに通知されるように設定します。

サービス内のキャラクタリスティックがdiscoverされると、以下のデリゲートコールバックが呼び出されます。

func peripheral(_ peripheral: CBPeripheral,
      didDiscoverCharacteristicsFor service: CBService, error: Error?) {
  // もしエラーが起きた場合、接続を遮断して最初からやり直せるようにする
  if let error = error {
    print("キャラクタリスティックが見つかりません: \(error.localizedDescription)")
    cleanUp()
    return
  }

  // 必要なキャラクタリスティックを指定する
  let characteristicUUID = CBUUID("890aa912-c414-440d-88a2-c7f66179589b")

  // キャラクタリスティックが複数ある場合があるためループする
  service.characteristics?.forEach { characteristic in
    guard characteristic.uuid == characteristicUUID else { return }

    // キャラクタリスティックを購読し、データが来たら通知されるようにする
    peripheral.setNotifyValue(true, for: characteristic)

    // データを送信するために、キャラクタリスティックの参照を保持する
    self.characteristic = characteristic
  }
}

ここでも、何かエラーが起きた場合は適切なエラー処理をおこないます。

service オブジェクトを受け取りましたが、サービス内に複数のキャラクタリスティックが含まれる可能性があるため、ループして必要なものを探しだします。

必要なキャラクタリスティックを見つけたら、peripheral.setNotifyValue()true にして呼び出し、
その中のデータに変更があったら通知を受け取るようにします。

ペリフェラルから、通知が設定されているかを確認する

次にペリフェラルが、セントラルへのキャラクタリスティックの通知の設定が正しく動作しているかを報告します。
成功したか失敗したかに関わらず、以下のデリゲートコールバックが呼び出されます。

func peripheral(_ peripheral: CBPeripheral,
                    didUpdateNotificationStateFor characteristic: CBCharacteristic,
                    error: Error?) {
  // 適切なエラー処理を行う
  // ここでのエラーでは接続自体を破棄する必要はない
  if let error = error {
    print("キャラクタリスティック更新通知エラー: \(error.localizedDescription)")
    return
  }

  // キャラクタリスティックが指定したものであることを確かめる
  guard characteristic.uuid == characteristicUUID else { return }

  // 通知の設定が成功しているかチェックする
  if characteristic.isNotifying {
    print("キャラクタリスティックの通知が開始されている")
  } else {
    print("キャラクタリスティックの通知が止まっています。接続をキャンセルします。")
    centralManager.cancelPeripheralConnection(peripheral)
  }

  // セントラルからペリフェラルに何か情報を送信する
}

必須ではありませんが、サブスクリプションが失敗した場合に再度購読を試みる(必要に応じてクリーンアップコードを実行する)のも良いと思います。
また、もしセントラルにペリフェラルに送信したいが保留中のデータがある場合は、このタイミングで送信するのが良いでしょう。

ペリフェラルにデータを送る

セントラルマネージャがペリフェラルと必要なキャラクタリスティックを見つけ出すことができたら、
このキャラクたりスティックを通じてセントラルはデータを送ることができます。

一つ注意しなければならないのは、このキャラクタリスティックはセントラルで書き込み可能になるように、ペリフェラル側で設定しておく必要があります。

let data = messageString.data(using: .utf8)!
peripheral.writeValue(data, for: characteristic, type: .withResponse)

type引数によって、データを受信したことをペリフェラルに返信させるかどうかを指定します。
これは特定の順序が必要なデータと、頻繁に繰り返されるデータを区別するのに便利で、ペリフェラルが受信できなくても値が失われることがありません。

セントラルにデータを送信する

反対にペリフェラルからセントラルにデータを送る場合も同様に記述します。

let data = messageString.data(using: .utf8)!
peripheralManager.updateValue(data,
        for: characteristic, onSubscribedCentrals: [central])

ペリフェラルからデータを受け取る

ここまででペリフェラルからデータを受信する準備ができました。
キャラクタリスティックに新しいデータが来た際に、以下のデリゲートを利用して通知を受け取ります。

func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
  // 適切なエラー処理を行う
  if let error = error {
    print("キャラクタリスティックの値の更新に失敗しました: \(error.localizedDescription)")
    return
  }

  // キャラクタリスティックから値を取り出す
  guard let data = characteristic.value else { return }

  // デコード/パース処理を行う
  let message = String(decoding: data, as: UTF8.self)
}

セントラルからデータを受け取る

最後に、ペリフェラルがセントラルからデータを受け取ると、 CBPeripheralManager の以下のメソッドが呼び出されます。

func peripheralManager(_ peripheral: CBPeripheralManager,
                      didReceiveWrite requests: [CBATTRequest]) {
  guard let request = requests.first, let data = request.value else { return }
  let message = String(decoding: data, as: UTF8.self)
}

特に注意しなければならないことは、キャラクタリスティックの作成時に書き込み可能(writeable)に設定されていなかった場合、
ペリフェラルへのデータ送信は静かに失敗し、このデリゲートは決して呼び出されないということです。

まとめ

ここまでを経て、Core Bluetoothでデータを送受信するには、かなりのステップ数が必要になることが分かります。

ペリフェラル側では、サービスとキャラクたりスティックを設定してアドバタイズし、セントラルが購読/購読中止をした場合に管理する必要があります。
セントラル側では、ペリフェラルをスキャンし、接続し、サービスを検索し、キャラクタリスティックを検索し、そして購読する必要があります。

とはいえ、用語と、それぞれのオブジェクトがどのように連鎖するかを理解すれば、比較的簡単に上手くいきます。

チャットアプリのデザインパターン

上記のCore Bluetooth APIの紹介ではセントラルとペリフェラルを接続するための基本的な手順を示していますが、
チャットアプリを作成する際、「誰がセントラルで誰がペリフェラルなのか」という大きな疑問にぶつかります。

低消費電力のセンサーがiPhoneに接続されているよくあるユースケースでは、セントラルとペリフェラルの役割は明確です。
しかし、2台のiPhoneが接続されている場合、誰がどちらの役割を果たすのかという問題は、突然、はるかに難しくなります。

最初の時点では理解していないかもしれないこととして、Core Bluetoothではデバイスは同時にセントラルにもペリフェラルにもなれるということがあります。
デバイスはスキャンしながらアドバタイズも同時に行うことができます。

アプリが起動してデバイス検索画面が開かれた時、セントラルマネージャとペリフェラルマネージャの両方が作成され、スキャンとアドバタイズを同時に開始します。

範囲内にあるデバイスも同じことをしています。このことによって、検出した他のデバイスをこちらの画面に表示できます。
同様に、他のデバイスもこちらのデバイスを検出して表示します。

デバイス検索画面ではチャット可能な全ての端末を検出しますが、チャット画面に入ると選択したデバイスからのメッセージのみを受信したいと考えます。

このケースではユーザーがデバイスを選択すると、そのデバイスのUUIDが保存され、チャット画面に渡されます。
両方のデバイスで同じチャット画面に入ると、どちらのデバイスも通常のセントラルとしてセットされ、まだ接続は行われません。
デバイス検索画面でアドバタイズしているデバイスを検知しないよう、別のサービスIDをここでの接続に使用します。

どちらかがメッセージを送信するまで、両方の端末はスキャンを続けます。
メッセージが送信すると、その端末はペリフェラルとなり自身をアドバタイズし始めます。
それを検知した他方のデバイスは、セントラルとしてペリフェラルと接続します。
最初にメッセージを送った人がペリフェラルになり、もう一人がセントラルのままで、一つの接続を共有します。
通信の間、デバイスUUIDは接続相手を識別するために使われ、アドバタイズを開始した他のセッションの誰かが誤って入ってこないことを保証します。

このやり方は少し変だと感じるかもしれません。
より実用的には、二つの接続を作成し維持する方が合理的かもしれません。
しかしその場合、途切れる可能性のある接続が2つ存在することになり、安定性が低くなる可能性があります。

技術に関しての考察

ここまでCore Bluetooth APIとそのデザインパターンについて説明してきました。
どのように動かせば良いかを理解するのは難しくないと思います。

それはなぜかというと、Core Bluetoothを動かすための最低限の部分のみ見てきたためです。
これは本番アプリには絶対的に不十分であるということです

Ditto社では、メインのプロダクトでCore Bluetoothを使い、さらにAndroidでのBluetooth Low Energyもサポートしています。
このプロジェクトで私が経験した課題や制限と、Dittoのエンジニアが直面した課題をいくつか紹介します。

メッセージのデータ容量の制限

私が全く理解していなかったことの一つは、キャラクタリスティックを通じて送信できるデータ量はかなり限られており、その制限はデバイスによって違うということです。
元々は20バイトのみでしたが、最近のスマートフォンでは180バイトあります。
メッセージあたりのデータ量の少ないチャットアプリではそれほど気になりませんが、本番アプリでは問題になる可能性があるでしょう。

Core Bluetoothでは各メッセージの許容可能な長さを検出することができます。
それ以上の長さを送信したい場合は、データを分割して複数のメッセージとして送信することになりますが、
その実装は独自で行う必要があります。

速度の制限

GATTを通じた通信の最大速度は、1秒あたり数キロバイトしかありません。
チャットアプリでは問題ありませんが、大きなアプリではこれがボトルネックになる可能性があります。
ユースケースによっては、メッセージのデータ量を最適化する必要が出てくるかもしれません。

安全な送信方法での更なる遅延

.withResponse を指定してペリフェラルが受信したことを保証する場合、この往復の動作が更なる遅延を発生させます。
速度を重視するユースケースでは、この方法を使わずに送信し、独自のエラー修正ロジックを実装するべきです。

プラットフォームごとの制御レベルの違い

Core Bluetoothの独自のポリシーが、Androidなどの他のデバイス上のBLEの実装とは噛み合わない可能性があります。
例として、Core Bluetoothがペリフェラルのアドバタイズメントパケットに含められるデータ量や種類に制限をかけていることです。
そのため、Androidでも同様のアプリを開発しようとしている場合、同じように動作するように注意を払う必要があります。

バックグラウンド起動でのセキュリティ/プライバシーポリシー

通常のBluetoothは画面の操作にかかわらず動作しますが、AppleはBLEを採用しているアプリに対して、厳しいプライバシーポリシーを課しています。
ペリフェラルデバイスのアプリがバックグラウンドになると、アドバタイズは続きますが、"Local Name"プロパティは含まれなくなります。
さらに、バックグラウンドになったセンターは、範囲内のどのペリフェラルからも、継続したアドバタイズメントを受け取らなくなります。

この制限は、新型コロナウイルス接触確認アプリをCore Bluetoothを使って実装しようとしていた組織にとって、大きな争点になりました。

使いこなすにはとても複雑なAPI

慣れれば作業が簡単になるのは確かですが、Core Bluetoothはすぐに使いこなせるほど簡単なフレームワークではありません。
データを送受信し、必要なデータを取れるようになるには、とても長いプロセスを辿る必要があります。

さらに、このステップはコールバックを経由して順番に行われます。
自分のユースケースに必要なプロセスを考え出すのは非常に時間がかかり、高い認知負荷が必要になると思います。

かなりしっかりとしたエラー処理が必要

デリゲートコールバックの処理のどの時点でも、プロセス全体を失敗させることが簡単に起こりえます。
ワイヤレス技術であるBluetoothは干渉に弱く、接続が途切れやすいです。
そのため、処理のどの段階でも発生しうる問題に対処するため、確実なエラー処理が必要になります。

予期したコールバックが発生しなかった場合に備えて、ハートビート(定期的にノード間で送信されるメッセージ)や状態チェックを行う必要がある可能性もあります。

不安定さと変な挙動

Core Bluetoothはすでにかなり古いものですが、たまに発生する変な動作は確実に残っています。

  • 送信キューが容量いっぱいになった場合、クリアしたというコールバックがスキップされることがあります。このため、定期的にキューの状態をテストする必要があります。
  • 特定のデバイス(iPad Mini 4やiPhone 6のような)は、ロックされた後にロックを解除すると、スキャンを誤って停止してしまう可能性があります。

暗号化が不足

Bluetoothで送信するデータの中には個人情報(健康記録など)が含まれる場合があるため、暗号化は常に強く推奨されています。
BLEにも暗号化はありますが、信頼性は高くありません。
そのため、暗号化レイヤーとそれに伴う(エラー修正など)の実装を全て独自で行う必要があるかもしれません。

まとめ

最初にチャットアプリが動作し、タイプした文字が別の端末に表示された時、魔法のようだと感じました。
この記事を書くためにCore Bluetoothを学んだのはとても有意義な時間でした。
読んでいただいた方にとっても有益であれば嬉しいです。

しかしながら、最後に一つはっきりとさせておきたいことがあります。
独自のCore Bluetoothの実装をするのはとても大変です
かなり多くのステップがあり、ユーザーの体験のどの部分においても上手くいかない可能性が大いにあります。

もしあなたがCore Bluetoothを調査しているエンジニアで、ローカル通信を実装したプロダクトを作ろうとしているのであれば、
Dittoの同期技術をチェックしてみることをお勧めします。
Dittoの技術スタックは上記の課題を全て解決しており、アプリに通信を実装することを簡単にしてくれます。

読んでいただきありがとうございました!

サンプルアプリのGithubリポジトリ

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

[iOS 14] XCUITestでアプリを削除する

アプリの初回起動時のみに表示される画面をテストする必要があったので、 XCUITest でアプリを削除する方法について調べました。

WEB上にいくつか参考になる記事がありましたが、iOS 14のアプリ削除フローに対応した実装方法が見当たらなかったので内容をまとめてみました。

iOS 14でのアプリ削除フロー

iOS 14では以下のようにアプリを削除します

アプリアイコン長押し コンテキストメニュー 削除確認アラート1 削除確認アラート2
Simulator Screen Shot - iPhone 11 Pro - 2021-03-03 at 15.05.15.png Simulator Screen Shot - iPhone 11 Pro - 2021-03-03 at 15.05.19.png Simulator Screen Shot - iPhone 11 Pro - 2021-03-03 at 15.05.23.png Simulator Screen Shot - iPhone 11 Pro - 2021-03-03 at 15.05.27.png
ホーム画面上で
削除するアプリのアイコンを長押し
コンテキストメニューで
アプリの削除を選択
ホーム画面からの削除確認アラートで
アプリの削除を選択
アプリ削除確認アラートで
アプリの削除を選択

ポイントは、アプリを削除(アンインストール)するための確認アラート(上記の 削除確認アラート2 )が表示される前に、アプリの情報を保持しつつホーム画面から削除するための確認アラート(上記の 削除確認アラート1 )が表示される点です。

なお、コンテキストメニューが表示されてもアプリアイコンの長押しをしたままにすると、各アプリアイコンの左上に削除を示すマイナス:no_entry:ボタンが表示されますが、このフローだと上手く削除できない場合があることが分かったので、今回はコンテキストメニューでアプリの削除を選択するフローを採用しました。

実装

上記を踏まえて、アプリを削除するプログラムを書いていきましょう。
今回は SpringBoard 1 を使ってアプリの削除フローをSwiftで記述します。

struct Springboard {
    private static let myAppName = "削除するアプリ名"
    private static let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")

    static func deleteApp(name: String = myAppName) {
        XCUIApplication().terminate()

        springboard.activate()

        XCUIDevice.shared.orientation = UIDeviceOrientation.portrait
        Thread.sleep(forTimeInterval: 2.0)

        let appIcon = springboard.icons[name]
        guard appIcon.exists else {
            return
        }
        appIcon.press(forDuration: 1.5)

        let preferredLanguageCode = NSLocale.preferredLanguages[0].prefix(2)

        // コンテキストメニュー
        let firstDeleteButtonText = preferredLanguageCode == "ja" ? "Appを削除" : "Remove App"
        springboard.buttons[firstDeleteButtonText].firstMatch.tap()
        Thread.sleep(forTimeInterval: 1.0)

        // 削除確認アラート1
        let secondDeleteButtonText = preferredLanguageCode == "ja" ? "Appを削除" : "Delete App"
        springboard.buttons[secondDeleteButtonText].firstMatch.tap()
        Thread.sleep(forTimeInterval: 1.0)

        // 削除確認アラート2
        let thirdDeleteButtonText = preferredLanguageCode == "ja" ? "削除" : "Delete"
        springboard.buttons[thirdDeleteButtonText].firstMatch.tap()

        Thread.sleep(forTimeInterval: 0.5)

        XCUIDevice.shared.press(.home)
    }
}

確認アラートの文言などはシミュレーターの言語設定によって変わるので、必要に応じて切り分けます。
英語の RemoveDelete で表記ゆれしているのは地味にハマりどころかもしれません :sweat_smile:

これをUIテストのアプリ起動前に呼び出せば、毎回クリーンな状態でテストを実行できます :tada:

import XCTest

class MyAppUITests: XCTestCase {
    override func setUp() {
        super.setUp()

        // In UI tests it is usually best to stop immediately when a failure occurs.
        continueAfterFailure = false

        // インスタンスメソッド `setUp()` で呼び出せば、各テストメソッドの実行前にアプリが削除される
        Springboard.deleteApp()

        // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method.
        XCUIApplication().launch()

        // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
    }

...

}

別フロー

コンテキストメニューでアプリの削除を選択せずにアプリアイコンを長押ししつづけていると、アプリアイコンたちがプルプルしはじめ、各アプリアイコンの左上には削除を示すマイナス:no_entry:ボタンが表示されます。

このフローでもアプリを削除できますが、環境やタイミングによっては想定外のアラートなどに邪魔されてしまうことがありました。

アプリアイコン長押し継続 確認アラート 想定外のアラート
コンテキストメニューが表示されても
無視して押し続ける
削除確認アラートが表示されることもあるが ホーム画面編集についてのお知らせアラートなどに
邪魔されてしまうこともある

もちろんこれらを適切にハンドリングできればテストは可能だと思いますが、今回は採用を見送りました。

参考

XCUITestでアプリを削除する


  1. iOSのホーム画面やそれに付随した機能を管理するためのソフトウェア 

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

TodoAppでRxSwift入門[part4]

概要

最近RxSwiftを勉強し始めて現在理解していることを備忘録として残せたらいいなと思い記事にします。
そもそもRxSwiftのRxとは

Rx(Reactive X)とは、「オブザーバパターン」「イテレータパターン」「関数型プログラミング」の概念を実装している拡張ライブラリです。
Rxを導入するメリットは、「値の変化を検知できる」「非同期の処理を簡潔に書ける」ということに尽きると思います。 値の変化というのは変数値の変化やUIの変化も含まれます。 例えばボタンをタッチする、という動作もボタンのステータスが変わったと捉えることができRxを使って記述することができます。

とのことです。
詳しくは以下のサイトを参照してください。
入門!RxSwift
RxSwiftについてようやく理解できてきたのでまとめることにした(1)

今回はPart3になります。
前回の記事はこちら
TodoAPPでRxSwift入門[part3]

今回でうまく修まれば最後になります。

TodoListViewModel

TodoListViewModel.swift
// Cellに渡す時に扱いやすいようにしています。
typealias TodoItemsSection = SectionModel<Int, TodoCellViewPresentable>

protocol TodoListPresentable {

    typealias Input = ()
    typealias Output = (
        todos: Driver<[TodoItemsSection]>, ()
    )

    var input: TodoListPresentable.Input { get }
    var output: TodoListPresentable.Output { get }
}

class TodoListViewModel: TodoListPresentable {
    var input: TodoListPresentable.Input
    var output: TodoListPresentable.Output

    private let storeManager: StoreManager

    init(input: TodoListPresentable.Input, storeManager: StoreManager) {
        self.input = input
        self.storeManager = storeManager
        self.output = TodoListViewModel.output(storeManager: self.storeManager)
    }
}

private extension TodoListViewModel {
    static func output(storeManager: StoreManager) -> TodoListPresentable.Output {
        let todos = storeManager.fetchTodosFromFirestore()
            .map { $0.compactMap { TodoCellViewModel(usingModel: $0) } }
            .map { [TodoItemsSection(model: 0, items: $0)] }
            .asDriver(onErrorJustReturn: [])

        return (
            todos: todos, ()
        )
    }
}

前回と同じような構造ですね。コメントでも書いてある通り、

typealias TodoItemsSection = SectionModel<Int, TodoCellViewPresentable>

の部分は扱いやすくするためです。

続いてoutput関数ですがmap関数で型変換をしてあげます。
この時新しいObservableに作り変えられる?(表現があってるのかわからない)そうです。
compactMapはSwift標準のもので、配列内のnilを取り除いてくれます。
以下を参照してください。
Swift で map, compactMap, flatMap を使いこなそう

TodoListViewController

TodoListViewController
import UIKit
import RxSwift
import RxCocoa
import RxDataSources

class TodoListViewController: UIViewController, Storyboardable {

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var dismissButton: UIBarButtonItem!

    private static let cellId = "TodoCell"

    private let dataSources = RxTableViewSectionedReloadDataSource<TodoItemsSection> { (_, tableView, indexPath, item) -> UITableViewCell in
        let cell = tableView.dequeueReusableCell(withIdentifier: TodoListViewController.cellId, for: indexPath) as! TodoCell
        cell.configure(usingViewModel: item)
        return cell
    }


    private let disposeBag = DisposeBag()
    private var viewModel: TodoListPresentable!
    private let storeManager = StoreManager()

    override func viewDidLoad() {
        super.viewDidLoad()

        self.viewModel = TodoListViewModel(input: (), storeManager: storeManager)

        setupViews()
        setupBinding()
    }

    private func setupViews() {
        tableView.register(UINib(nibName: "TodoCell", bundle: nil), forCellReuseIdentifier: TodoListViewController.cellId)
        tableView.separatorStyle = .none
    }

    private func setupBinding() {
        self.viewModel.output.todos
            .drive(tableView.rx.items(dataSource: self.dataSources))
            .disposed(by: disposeBag)

        dismissButton.rx.tap.subscribe(onNext: { [weak self] in
            self?.dismiss(animated: true)
        }).disposed(by: disposeBag)
    }

}

コード自体はそこまで長くないですね。まずは以下のコードからみていきましょう。

private let dataSources = RxTableViewSectionedReloadDataSource<TodoItemsSection> { (_, tableView, indexPath, item) -> UITableViewCell in
        let cell = tableView.dequeueReusableCell(withIdentifier: TodoListViewController.cellId, for: indexPath) as! TodoCell
        cell.configure(usingViewModel: item)
        return cell
    }

RxTableViewSectionedReloadDataSourceRxDataSourcesが提供してくれているので導入しておいてください。
<TodoItemsSection>は先程のTodoListViewModelでtypealiasしたものですね。
指定なかったら

<SectionModel<Int, TodoCellViewPresentable>>

と書きます。

そしてitemTodoCellViewPresentableが流れてくるのでそれに準拠した型のものになります。

setupBingin関数の中も特に難しいことはないと思います。

まとめ

part分けする予定もなかった本記事ですが、長くなってしまいました。
また本記事では出てきていないclassなどがあると思いますが、解説は要らないレベルのものになっていますのでGithubをみてください。
github

書く前よりほんの少し理解が進んだかな?という印象です。
もっと勉強して完全に理解したになれるように頑張りたいですねw

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

UITableViewCellに追加したSwiftUIのボタンが反応しない

iOS14の端末でUITableViewの中身のみをSwiftUIでビューを作成した際にボタンが反応しない問題がありました。(iOS13ではボタンの反応がありましたのでOSのバージョンによって挙動が異なるようです。)

以下の様にボタンのみが配置されているシンプルな画面を作成します。

スクリーンショット 2021-03-03 14.53.46.png

import UIKit
import SwiftUI

class ViewController: UIViewController {

    @IBOutlet private weak var tableView: UITableView!

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

}

extension ViewController: UITableViewDelegate, UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 10
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .default, reuseIdentifier: "Cell")
        let view = ButtonView()
        let hostingController = UIHostingController(rootView: view)
        cell.addSubview(hostingController.view)

        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        hostingController.view.heightAnchor.constraint(equalToConstant: 320).isActive = true
        hostingController.view.bottomAnchor.constraint(equalTo: cell.bottomAnchor, constant: 16).isActive = true
        hostingController.view.topAnchor.constraint(equalTo: cell.topAnchor, constant: 16).isActive = true
        hostingController.view.leftAnchor.constraint(equalTo: cell.leftAnchor, constant: 16).isActive = true
        hostingController.view.rightAnchor.constraint(equalTo: cell.rightAnchor, constant: -16).isActive = true
        hostingController.view.centerYAnchor.constraint(equalTo: cell.centerYAnchor).isActive = true
        cell.selectionStyle = .none
        return cell
    }


}

●ButtonView

struct ButtonView: View {
    var body: some View {
        HStack {
            Spacer()
            VStack(alignment: .leading) {
                Spacer()
                Button(action: {
                    print("タップ")
                }) {
                    Text("ボタン")
                        .foregroundColor(Color(.blue))
                        .font(.system(size: 14))
                        .padding([.leading, .trailing], 20)
                        .overlay(
                            RoundedRectangle(cornerRadius: 14)
                                .strokeBorder(Color(.blue), lineWidth: 1)
                        )
                }
                Spacer()
            }
            Spacer()
        }
    }
}

「Debug View Hierarchy」で階層を見てみると以下の様になっていました。

スクリーンショット 2021-03-03 15.03.46.png

ボタンの上にcontentViewが乗っかってしまっています。
これはcell.addSubviewとしているのが問題で、正しくはcell.contentView.addSubviewとしなければなりませんでした。

OSによって挙動が異なるようですが、パッと見では分かりづらいため備忘録として残しておきます。

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

SwiftUI ベースのプロジェクトでの FCM 実装注意点

はじめに

基本的な実装方法・証明書周りの設定方法は日本語の記事も多いのでそちらをご覧ください。
https://firebase.google.com/docs/cloud-messaging/ios/client?hl=ja

今回は SwiftUI でプロジェクトを作成した場合、つまり AppDelegate を後から追加した場合の注意点をご紹介します。

結論

FCM SDK は deviceToken を取得する処理の実装を Method Swizzling を利用して実現しています。ただし、これが正常に動作するのは AppDelegate@main として利用している場合に限ります。
なので、 SDK の Method Swizzling に頼らず、以下の処理を明示的に記述する必要があります。

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    Messaging.messaging().apnsToken = deviceToken
}

詳細

SwiftUI をベースにプロジェクトを作成した場合、以下のような形で AppDelegate を実装することになります。
この場合、 FCM SDK による Method Swizzling が AppDelegate に対して走らないため、開発者側で明示的に追記してあげる必要があります(ここの挙動は細かく調べたわけではないので、間違いがあればご指摘お願いします)

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ...
        }
    }
}

final class AppDelegate: UIResponder, UIApplicationDelegate {
    typealias LaunchOptionsKey = UIApplication.LaunchOptionsKey
    func application(_ application: UIApplication, didFinishLaunchingWithOptions: [LaunchOptionsKey: Any]? = nil) -> Bool {
        return true
    }
}

詳しくはここに記述されています。
実装入れ替えが無効な場合の APNs トークンと登録トークンとのマッピング

さいごに

結論として、公式ドキュメントにもしっかり書かれている内容だったということになりますが、 SwiftUI メインでアプリを開発する場合は特に注意したほうがいいかと思い、記事として残してみました。
公式ドキュメント・SDK ともに後々仕様が変わる可能性がありますので、まずは最新の情報を確認した上でこの記事も参考にしていただければと思います。

最後まで読んでいただき、ありがとうございます!

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

インスタグラムのマイページのようなViewを作った

概要

インスタグラム,Tiktok,Twitterのマイページで見るようなページを実装してOSSとして公開しました。


TopContentPager とは?

上記のような複雑なページを簡単に作ることができるフレームワークです。
上記のようなページの特徴としては以下。

1. 中断にページのタブがあり、上にスクロールすることで上部に固定される
2. 全てのページで共通した縦スクロールが可能なヘッダーが存在する
3. 上にスクロールしてページタブが固定されている状態でページを切り替えると各スクロール量は維持されているが、1つのページで上部まで(ページタブが固定されない部分まで)スクロールした状態でページを切り替えると全てスクロールはトップまで戻ってきている

これらの複雑なロジックを考えることなく、ページの実装を実現できます。

使い方

導入

まずはライブラリをinstallしてください。
Podfileに下記を追加して pod install をすればinstall完了です。

Podfile
pod 'TopContentPager'

実装

使うものは
- ContentTableBody
- TopContentView
- TopContentPagerViewController

のみです。

親ViewControllerの作成

まず、大元となる親ViewControllerを実装します。
TopContentPagerViewController クラスを継承した ParentViewController を実装します。
ParentViewController には TopContentPagerDataSource を設定してください。
DataSourceには下記の二つの関数があります

func topContentPagerViewControllerTopContentView(_ viewController: TopContentPagerViewController) -> TopContentView
func topContentPagerViewControllerViewControllers(_ viewController: TopContentPagerViewController) -> [ContentTableBody]

それぞれ

  • TopContentView を継承した共通のヘッダー部分になるView
  • ContentTableBody プロトコルの適応した各ページのViewになるViewControllerの配列

を返します。

上記の関数を設定したら、下記の関数でdataSourceをselfに設定してください。

func setupWillLoadDataSource()

これで以下のようになり、最低限の親ViewControllerの実装は完了です。

ParentViewController
final class ParentViewController: TopContentPagerViewController {
    override func setupWillLoadDataSource() {
        super.setupWillLoadDataSource()
        dataSource = self
    }

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

extension ParentViewController: TopContentPagerDataSource {
    func topContentPagerViewControllerTopContentView(_ viewController: TopContentPagerViewController) -> TopContentView {
        // return common HeaderView in all pages.
        TopView()
    }

    func topContentPagerViewControllerViewControllers(_ viewController: TopContentPagerViewController) -> [ContentTableBody] {
        // return ViewControllers for each page.
        [Page1ViewController(), Page2ViewController(), Page3ViewController()]
    }
}

共通のヘッダーViewの作成

次に共通となるヘッダーViewを作成します。
TopContentView を継承したクラス TopView を実装します。

TopView
final class TopView: TopContentView { }

はい、これだけです。あとはいつも通り普通にViewを作ってください。xibにしてもコードにしてもAutoLayoutを使うことを推奨します。

各ページのViewControllerの作成

最後にそれぞれのページとなるViewControllerを作っていきます。
ContentTableBody を継承させたViewControllerを実装します。
ContentTableBody の定義は以下です。

ContentTableBody
public protocol ContentTableBody: UIViewController {
    var pagerItem: PagerItem { get }
    var scrollView: UIScrollView! { get }
    // 〜省略
}

pagerItem はページのタブ部分のデザインを設定するものです。
.text .image .textAndImage .custom があり好きなものを設定してください。
詳細はここにあります。
-> https://github.com/itsukiss/TopContentPager#pageritem

scrollView にはコンテンツとなるViewを設定してください、基本的には tableViewcollectionView です。

下記が最低限設定した時の実装です。

Page1ViewController
class Page1ViewController: UIViewController, ContentTableBody {

    var pagerItem: PagerItem = .text(.init(title: "ページ1"))
    var scrollView: UIScrollView!
    @IBOutlet weak var tableView: UITableView! {
        didSet {
            scrollView = tableView
        }
    }
}

完成

これだけで上記のような共通のヘッダーがあるPagerを作ることができます。
上記の実装では必要最低限の説明しかしていませんので、READMEサンプルプログラムを見ることをお勧めします。
特にサンプルプログラムはわかりやすく作ってますのでぜひ参考にしてください。

設計

View構造は下記のようになっていて、基本的には下記のような実装をしています。
(READMeにもかいてますが、 ContentTopProtocol はもはや使われていません。 TopContentView に置き換えて読んでください)
また、各スクロール時のViewの挙動はGifのアニメーションで表しているので、みていただけるとイメージがわくと思います。

  • 横スクロール時に一番最前面のEscapeViewにTopContentViewを貼り付ける。
  • 縦スクロール時にはContentTopCellに貼り付けることで縦スクロールのジェスチャも可能に
  • ある一定以上までスクロールしたら、TopContentViewをEscapeViewに貼り付けページタブ部分だけをView内にみえるように
  • TopContentViewではhitTestをoverrideして判定し、横方向のスクロールを制御しています。
  • どこかのページの縦スクロールがTopContentViewまで到達したとき(つまり上部固定のヘッダーが表示された時)に全てのページのcontentOffsetを合わせるように
    • これはことばにするとややこしいですが、上部コンテンツが共通の場合上部コンテンツが見えてる場合に横スクロールすると他のページのコンテンツがcontentOffsetを保持していると、TopContentViewに食い込むような形になるからです。
    • 下のgifアニメーションの最後の「スクロールがどちらも下の方まで行われていた時を見ればわかります。

1.View構造/TopContentView内の横スクロール制御
TopContentPagerStructure.png
2.スクロール時のViewの挙動

ここでは細かく説明しませんが、もし気になる方はコードを読んでみてください。

終わりに

今回はぱっと見て簡単にできそうだけど、やってみたら結構複雑なインスタグラムやtiktokのマイページのようなPagerを作りました。
困っている人の助けになればと思います。
もし何か質問や意見があれば、ガンガンください。

リンク集

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

インスタグラムのマイページのようなViewを簡単に作れるライブラリを公開しました

概要

インスタグラム,Tiktok,Twitterのマイページで見るようなページを実装してOSSとして公開しました。


TopContentPager とは?

上記のような複雑なページを簡単に作ることができるフレームワークです。
上記のようなページの特徴としては以下。

1. 中断にページのタブがあり、上にスクロールすることで上部に固定される
2. 全てのページで共通した縦スクロールが可能なヘッダーが存在する
3. 上にスクロールしてページタブが固定されている状態でページを切り替えると各スクロール量は維持されているが、1つのページで上部まで(ページタブが固定されない部分まで)スクロールした状態でページを切り替えると全てスクロールはトップまで戻ってきている

これらの複雑なロジックを考えることなく、ページの実装を実現できます。

使い方

導入

まずはライブラリをinstallしてください。
Podfileに下記を追加して pod install をすればinstall完了です。

Podfile
pod 'TopContentPager'

実装

使うものは
- ContentTableBody
- TopContentView
- TopContentPagerViewController

のみです。

親ViewControllerの作成

まず、大元となる親ViewControllerを実装します。
TopContentPagerViewController クラスを継承した ParentViewController を実装します。
ParentViewController には TopContentPagerDataSource を設定してください。
DataSourceには下記の二つの関数があります

func topContentPagerViewControllerTopContentView(_ viewController: TopContentPagerViewController) -> TopContentView
func topContentPagerViewControllerViewControllers(_ viewController: TopContentPagerViewController) -> [ContentTableBody]

それぞれ

  • TopContentView を継承した共通のヘッダー部分になるView
  • ContentTableBody プロトコルの適応した各ページのViewになるViewControllerの配列

を返します。

上記の関数を設定したら、下記の関数でdataSourceをselfに設定してください。

func setupWillLoadDataSource()

これで以下のようになり、最低限の親ViewControllerの実装は完了です。

ParentViewController
final class ParentViewController: TopContentPagerViewController {
    override func setupWillLoadDataSource() {
        super.setupWillLoadDataSource()
        dataSource = self
    }

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

extension ParentViewController: TopContentPagerDataSource {
    func topContentPagerViewControllerTopContentView(_ viewController: TopContentPagerViewController) -> TopContentView {
        // return common HeaderView in all pages.
        TopView()
    }

    func topContentPagerViewControllerViewControllers(_ viewController: TopContentPagerViewController) -> [ContentTableBody] {
        // return ViewControllers for each page.
        [Page1ViewController(), Page2ViewController(), Page3ViewController()]
    }
}

共通のヘッダーViewの作成

次に共通となるヘッダーViewを作成します。
TopContentView を継承したクラス TopView を実装します。

TopView
final class TopView: TopContentView { }

はい、これだけです。あとはいつも通り普通にViewを作ってください。xibにしてもコードにしてもAutoLayoutを使うことを推奨します。

各ページのViewControllerの作成

最後にそれぞれのページとなるViewControllerを作っていきます。
ContentTableBody を継承させたViewControllerを実装します。
ContentTableBody の定義は以下です。

ContentTableBody
public protocol ContentTableBody: UIViewController {
    var pagerItem: PagerItem { get }
    var scrollView: UIScrollView! { get }
    // 〜省略
}

pagerItem はページのタブ部分のデザインを設定するものです。
.text .image .textAndImage .custom があり好きなものを設定してください。
詳細はここにあります。
-> https://github.com/itsukiss/TopContentPager#pageritem

scrollView にはコンテンツとなるViewを設定してください、基本的には tableViewcollectionView です。

下記が最低限設定した時の実装です。

Page1ViewController
class Page1ViewController: UIViewController, ContentTableBody {

    var pagerItem: PagerItem = .text(.init(title: "ページ1"))
    var scrollView: UIScrollView!
    @IBOutlet weak var tableView: UITableView! {
        didSet {
            scrollView = tableView
        }
    }
}

完成

これだけで上記のような共通のヘッダーがあるPagerを作ることができます。
上記の実装では必要最低限の説明しかしていませんので、READMEサンプルプログラムを見ることをお勧めします。
特にサンプルプログラムはわかりやすく作ってますのでぜひ参考にしてください。

設計

View構造は下記のようになっていて、基本的には下記のような実装をしています。
(READMeにもかいてますが、 ContentTopProtocol はもはや使われていません。 TopContentView に置き換えて読んでください)
また、各スクロール時のViewの挙動はGifのアニメーションで表しているので、みていただけるとイメージがわくと思います。

  • 横スクロール時に一番最前面のEscapeViewにTopContentViewを貼り付ける。
  • 縦スクロール時にはContentTopCellに貼り付けることで縦スクロールのジェスチャも可能に
  • ある一定以上までスクロールしたら、TopContentViewをEscapeViewに貼り付けページタブ部分だけをView内にみえるように
  • TopContentViewではhitTestをoverrideして判定し、横方向のスクロールを制御しています。
  • どこかのページの縦スクロールがTopContentViewまで到達したとき(つまり上部固定のヘッダーが表示された時)に全てのページのcontentOffsetを合わせるように
    • これはことばにするとややこしいですが、上部コンテンツが共通の場合上部コンテンツが見えてる場合に横スクロールすると他のページのコンテンツがcontentOffsetを保持していると、TopContentViewに食い込むような形になるからです。
    • 下のgifアニメーションの最後の「スクロールがどちらも下の方まで行われていた時を見ればわかります。

1.View構造/TopContentView内の横スクロール制御
TopContentPagerStructure.png
2.スクロール時のViewの挙動

ここでは細かく説明しませんが、もし気になる方はコードを読んでみてください。

終わりに

今回はぱっと見て簡単にできそうだけど、やってみたら結構複雑なインスタグラムやtiktokのマイページのようなPagerを作りました。
困っている人の助けになればと思います。
もし何か質問や意見があれば、ガンガンください。

リンク集

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

SwiftUI で UIPickerView を使う

はじめに

 iPhone 標準の時計アプリのタイマーの時間設定のような Picker を使いたかったのですが SwiftUI の Picker ではそのような見た目にはできないようだったので、UIPickerView で実装することにしたのですが、SwiftUI と組み合わせる情報が見つかりませんでした。そんな中でなんとか実装できたので、記録を残しておきます。

 ちなみにいまさらながら初めての iPhone ネイティブアプリ開発でした。

課題

 実装するにあたり以下が課題となりました。
1. SwiftUI と UIPickerView の連携
2. ラベルを固定する
3. Picker から値を受け渡す

これを踏まえて実装を順に紹介していきます。

実装

 実装を順に説明します。

SwiftUI と UIPickerView の連携

 まず、ガワだけを示すと次のようなコードになります。

import SwiftUI

struct TimePickerView: UIViewRepresentable {

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UIPickerView {
        let picker = UIPickerView()
        picker.dataSource = context.coordinator
        picker.delegate = context.coordinator

        return picker
    }

    func updateUIView(_ uiView: UIPickerView, context: Context) {
    }

    class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
        var timerPickerView: TimePickerView

        init(_ view: TimePickerView) {
            self.timerPickerView = view
        }

        func numberOfComponents(in pickerView: UIPickerView) -> Int {
            <#code#>
        }

        func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
            <#code#>
        }

        func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
            <#code#>
        }
    }
}

struct TimePickerView_Previews: PreviewProvider {
    static var previews: some View {
        TimePickerView()
    }
}

Picker の値を選択できるようにする

 次に Picker の値を初期化して選択できるようにします。ただし、これだと問題があって時、分、秒のラベルも操作できてしまいます。これは次で修正をします。

import SwiftUI

struct TimePickerView: UIViewRepresentable {

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UIPickerView {
        let picker = UIPickerView()
        picker.dataSource = context.coordinator
        picker.delegate = context.coordinator

        return picker
    }

    func updateUIView(_ uiView: UIPickerView, context: Context) {
    }

    class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
        var timerPickerView: TimePickerView
        var dataList = [["0"], [""], ["0"], [""], ["0"], [""]]

        init(_ view: TimePickerView) {
            self.timerPickerView = view

            for i in 1...23 {
                dataList[0].append("\(i)")
            }
            for i in 1...59 {
                dataList[2].append("\(i)")
            }
            for i in 1...59 {
                dataList[4].append("\(i)")
            }
        }

        func numberOfComponents(in pickerView: UIPickerView) -> Int {
            return dataList.count
        }

        func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
            return dataList[component].count
        }

        func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
            return dataList[component][row]
        }
    }
}

struct TimePickerView_Previews: PreviewProvider {
    static var previews: some View {
        TimePickerView()
    }
}

ラベルを固定する

ラベルを固定するために UILabel を生成して UIPickerView のサブビューとします。(個人的には他にもっと簡単なやり方があるのではないかと疑ってしまうのですが)

import SwiftUI

struct TimePickerView: UIViewRepresentable {

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UIPickerView {
        let picker = UIPickerView()
        picker.dataSource = context.coordinator
        picker.delegate = context.coordinator

        let fontSize:CGFloat = 20
        let labelWidth:CGFloat = picker.frame.size.width / CGFloat(picker.numberOfComponents)
        let y:CGFloat = (picker.frame.size.height / 2) - (fontSize / 2)

        for i in [1, 3, 5] {
            let label = UILabel()
            switch i {
            case 1:
                label.text = "時"
            case 3:
                label.text = "分"
            default:
                label.text = "秒"
            }
            label.frame = CGRect(x: labelWidth * CGFloat(i), y: y, width: labelWidth, height: fontSize)
            label.font = UIFont.systemFont(ofSize: fontSize, weight: .light)
            label.backgroundColor = .clear
            label.textAlignment = NSTextAlignment.center
            picker.addSubview(label)
        }

        return picker
    }

    func updateUIView(_ uiView: UIPickerView, context: Context) {
    }

    class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
        var timerPickerView: TimePickerView
        var dataList = [["0"], [""], ["0"], [""], ["0"], [""]]

        init(_ view: TimePickerView) {
            self.timerPickerView = view

            for i in 1...23 {
                dataList[0].append("\(i)")
            }
            for i in 1...59 {
                dataList[2].append("\(i)")
            }
            for i in 1...59 {
                dataList[4].append("\(i)")
            }
        }

        func numberOfComponents(in pickerView: UIPickerView) -> Int {
            return dataList.count
        }

        func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
            return dataList[component].count
        }

        func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
            return dataList[component][row]
        }
    }
}

struct TimePickerView_Previews: PreviewProvider {
    static var previews: some View {
        TimePickerView()
    }
}

Picker から値を受け渡す

 値を受け渡すために状態変数を用意します。ただし、アニメーションが終わったタイミングで値が確定されるということに注意が必要です。(アニメーションを中断して値を確定する方法をご存知の方がいれば教えてください)

import SwiftUI

struct TimePickerView: UIViewRepresentable {
    @Binding var hour: Int
    @Binding var minute: Int
    @Binding var second: Int

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UIPickerView {
        let picker = UIPickerView()
        picker.dataSource = context.coordinator
        picker.delegate = context.coordinator

        let fontSize:CGFloat = 20
        let labelWidth:CGFloat = picker.frame.size.width / CGFloat(picker.numberOfComponents)
        let y:CGFloat = (picker.frame.size.height / 2) - (fontSize / 2)

        for i in [1, 3, 5] {
            let label = UILabel()
            switch i {
            case 1:
                label.text = "時"
            case 3:
                label.text = "分"
            default:
                label.text = "秒"
            }
            label.frame = CGRect(x: labelWidth * CGFloat(i), y: y, width: labelWidth, height: fontSize)
            label.font = UIFont.systemFont(ofSize: fontSize, weight: .light)
            label.backgroundColor = .clear
            label.textAlignment = NSTextAlignment.center
            picker.addSubview(label)
        }

        return picker
    }

    func updateUIView(_ uiView: UIPickerView, context: Context) {
    }

    class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
        var timerPickerView: TimePickerView
        var dataList = [["0"], [""], ["0"], [""], ["0"], [""]]

        init(_ view: TimePickerView) {
            self.timerPickerView = view

            for i in 1...23 {
                dataList[0].append("\(i)")
            }
            for i in 1...59 {
                dataList[2].append("\(i)")
            }
            for i in 1...59 {
                dataList[4].append("\(i)")
            }
        }

        func numberOfComponents(in pickerView: UIPickerView) -> Int {
            return dataList.count
        }

        func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
            return dataList[component].count
        }

        func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
            return dataList[component][row]
        }

        func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
            switch component {
            case 0:
                timerPickerView.hour = row
            case 2:
                timerPickerView.minute = row
            case 4:
                timerPickerView.second = row
            default: break
            }
        }
    }
}

struct TimePickerView_Previews: PreviewProvider {
    static var previews: some View {
        TimePickerView(hour: .constant(0), minute: .constant(0), second: .constant(0))
    }
}

おわりに

 試行錯誤した割にはソースコードは大した分量ではなかったですね。同じようなことで困った方のお役に立てば幸いです。

参考

 実装にあたり、以下の記事を参考にさせていただきました。

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

Firestoreに保存されたデータを端末によって分ける方法

はじめに

タイトルにも書いてある通り、Firestoreに保存されたデータを端末によって分ける方法です。

導入方法などは割愛します。

Firestore独自の用語が沢山出てくるのでよく分からない方は、こちらを読んでみて下さい。
Cloud Firestoreについてまとめる

後はセキュリティルールについても。
Firestoreのセキュリティルールとは

まあ、自分のQiitaの記事を紹介しているのになんですが、やはり公式ドキュメントが一番良きです。
Cloud Firestore | Firebase

書こうと思った理由

現在、開発しているアプリにて、お気に入り登録機能を追加しようと考えており
Firestoreで、そのデータを管理しようと思ったのが事の発端です。

データの読み書きはすんなり出来たものの、そのデータをユーザーの端末によって分けるというのがどうしても出来ませんでした。

どうにかメンターに相談したり自分なりに試行錯誤したのち、どうにか出来たので備忘録としてまとめようと思いました。

やり方

1.Firebase Authenticationの導入

まずは、Firebase Authenticationをアプリに導入して下さい。

そして今回はメールアドレス/パスワードを使用したユーザー認証を導入したていで書きます。
(他の認証方法をあまり使ったことがない...)

導入して実際にAuthアカウントを作成すると、それぞれのユーザーに対してuidというユーザーIDが付与されます。
これはユーザーによってバラバラであり、重複していません。

このuidを使ってFirestoreに保存されたデータを端末ごとに分けていきます。

2.FirestoreのドキュメントIDをuidで登録する

FirestoreのドキュメントIDは通常、追加と同時にランダムなIDが付与されます。

なのでこのドキュメントIDを先ほどのuidで登録することによってユーザーごとにデータを分けることが出来ます。

例えば、食べ物の名前をFirestoreに保存しようとします。

// Firestoreにデータを追加するメソッド
func addFoodData(userId: String, foodName: String, complition: ((Error?) -> Void)? = nil) {

    Firestore.Firestore().collection("users").document(userId).collection("food").addDocument(data: [
        "Name": foodName

    ]) { (error) in
        if let error = error {
            complition?(error)
            return
        }
    }
}

メソッドのuserId引数から、uidを取得できるようにして
.document(userId)からFirestoreのドキュメントIDをuidで登録しました。

これでFirestoreにデータを保存するたびに、uid(ドキュメントID)からユーザーを分けることができ、端末ごとにデータを分けることができます

上記のコードが保存されたら、こんな感じでFirestoreにて管理されます↓

?・・・コレクション、サブコレクション
?・・・ドキュメント
ユーザーAのuid・・・123
ユーザーBのuid・・・456

・ユーザーAの場合
?users
  ?123
    ?food
      ?自動生成したID
        Name: "Apple"

・ユーザーBの場合
?users
  ?456
    ?food
      ?自動生成したID
        Name: "Sushi"

ただこの方法でもいいが・・・

Firestoreのセキュリティルールを書くときに少しだけ問題があります。

まずは公式が推奨しているセキュリティルールを見てください。
安全でないルールを修正する | Firebase

見ていただけると分かるようにFirebase Authenticationと連携したセキュリティルールは、認証済みユーザーが存在していてFirestoreに保存してあるuidとAuthアカウントのuidが一致していればデータの読み書きを許すという感じだと思います。

認証済みユーザーが存在していての部分は、Firebase Authenticationを導入していればOKですがFirestoreに保存してあるuidとAuthアカウントのuidが一致していればの部分は現状、満たせていません。

なぜなら、あくまで今までの方法はドキュメントIDをuidで登録しているだけでFirestoreにデータとして保存している訳ではないからです。

ではどうすればいいのか?

解決方法は簡単で、Firestoreにuidを保存すれば解決します。

先ほどのFirestoreにデータを追加するメソッドを少しだけ改修しましょう。

// Firestoreにデータを追加するメソッド
func addFoodData(userId: String, foodName: String, complition: ((Error?) -> Void)? = nil) {
    // uidをここで保存する
    Firestore.Firestore().collection("users").document(userId).setData([
            "userId": userId

      ]) { (error) in
          if let error = error {
             complition?(error)
             return
         }
    }

    Firestore.Firestore().collection("users").document(userId).collection("food").addDocument(data: [
        "Name": foodName

    ]) { (error) in
        if let error = error {
            complition?(error)
            return
        }
    }
}

上記のコードがFirestoreに保存されたら、こんな感じ↓

?・・・コレクション、サブコレクション
?・・・ドキュメント
ユーザーAのuid・・・123
ユーザーBのuid・・・456

・ユーザーAの場合
?users
  ?123
    userId: 123
     ?food
       ?自動生成したID
         Name: "Apple"

・ユーザーBの場合
?users
  ?456
    userId: 456
     ?food
       ?自動生成したID
         Name: "Sushi"

これでやっと、セキュリティルール内でAuthアカウントのuidとFirestoreに保存したuserIdを比較することができますね。

セキュリティルールも設定していきましょう。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

  function isAuth(userId) {
    return request.auth != null && userId == request.auth.uid;
  }  
    match /users/{userId}/food/{document} {
        allow write, read: if isAuth(userId);
    }
  }
}

これで、Firestoreに保存されたデータの安全性が保たれます。

おわりに

最後のメソッドのコードはちゃんと動きますが、もしかすると
もっと良い書き方があるかもしれません。

詳しい方はコメントでアドバイスして頂けると有り難いです。

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

関数の→ 型について

関数でよく見る

func sample() → なんとか

これについて余裕がなく調べることも聞くこともできなかったんですが、実務を通して理解したので忘備録

func sample() → なんとか

上記の → なんとかの部分には型が入っていて

必要ない場合は書かなくて良い

必要というか型を指定したいところは

配列であったり、文字列であったりいろいろ使える

例えばこんな使い方もできる

func a() → 取得したいデータ

func a() → 取得したい配列

この前見たパターンはもしデータベースの情報を取得して同じクラス上でやるとしたら、ループを回してその中でifでゴニョゴニョしてみたいな処理を関数でデータを取れるようにして欲しい場所だけに適用するみたいなパターン。

こんな使い方ができるようになったら楽しいだろうなと思いつつ、仕組みを考えるのが結構大変そうだななんて思いました。

あまり参考にならないかもですが、自身の忘備録なんで時間の余裕のある方がふーーーんそうなんだみたいな感じで流し読みするのがちょうどいいのかなと。

そんな感じで終わりにします。

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

関数の→型について

関数でよく見る

func sample() → なんとか

これについて余裕がなく調べることも聞くこともできなかったんですが、実務を通して理解したので忘備録

func sample() → なんとか

上記の → なんとかの部分には型が入っていて

必要ない場合は書かなくて良い

必要というか型を指定したいところは

配列であったり、文字列であったりいろいろ使える

例えばこんな使い方もできる

func a() → 取得したいデータ

func a() → 取得したい配列

この前見たパターンはもしデータベースの情報を取得して同じクラス上でやるとしたら、ループを回してその中でifでゴニョゴニョしてみたいな処理を関数でデータを取れるようにして欲しい場所だけに適用するみたいなパターン。

こんな使い方ができるようになったら楽しいだろうなと思いつつ、仕組みを考えるのが結構大変そうだななんて思いました。

あまり参考にならないかもですが、自身の忘備録なんで時間の余裕のある方がふーーーんそうなんだみたいな感じで流し読みするのがちょうどいいのかなと。

そんな感じで終わりにします。

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

【1回頓挫】初心者がApp StoreにiOS自作アプリを公開する過程

なぜ書くのか

つまずいたポイントを記録するため。
僕と同じように、初めてApp Storeに自作アプリを公開しようとしている人の参考にしてもらうため。

主に以下の記事を参考にさせていただいており、適宜参照します。
【2020年度版】 iOSアプリをApp Storeに公開する手順を簡潔に解説

つまずいたポイント

プライバシーポリシー用のサイト作成

つまずいたのは、github Pagesに作成したはずのプライバシポリシーが表示されないという内容です。
表示されない原因は、プライバシーポリシーが書いてあるhtmlファイルの名前が「index.html」で無かったことでした。

プライバシーポリシーは、テンプレート作成サイト(App Privacy Policy Generator)で作成し、公開はgithub Pagesで行いました。
完成したプライバシーポリシー以下URLから見ることができます。
プライバシーポリシー

参考にした記事は以下の二つです。
個人開発者がAppStore用にプライバシーポリシーを書く
iOSでプラポリが必須になったのでGitHub Pagesで対応した

App Developer Programの登録に時間がかかるということ

iOSアプリをApp Storeに公開するためにはApp Developer Programの登録が必要ですが、登録料金(税別11800円)を支払ってからいくらか時間が必要です。登録と審査への提出をそのままやるつもりでしたが、できないようです。

約48時間後、登録完了のメールが届きました。

iOSアプリ審査の指摘事項

審査請求後、これも約48時間後にレビュー結果が送られてきました。
3つ指摘事項がありました。

Guideline 1.5 - Safety - Developer Information

(2021/2/22)サポートURLが不適切なようです。twitterのURLを提出したのですが、リジェクトされてしましました。以下の記事ではアプリのスクリーンショットと連絡先を明記すれば良いとのことですが。しかし、以下の記事では自分のブログを持っており、そこにサポートURL用のページを追加することで対処されていました。私は個人ブログを持ち合わせていないので、以下のツイートのURLで審査が通るか試してみます。

スクリーンショット 2021-02-22 8.36.16.png

【AppStore】Guideline 1.5でリジェクトされた時の対処法

Guideline 2.3.7 - Performance - Accurate Metadata

(2021/2/22)タイトルが不適切なようです。以下の記事に3つ以上のキーワードが入っているとリジェクトされやすいと書いてありましたので、減らしました。

訂正前
AtamaConcrete 台湾中国語、日本語勉強アプリ

訂正後
AtamaConcrete 日台言語学習アプリ

【AppStore】アプリ名におけるキーワードについて

Guideline 4.2 - Design - Minimum Functionality

(2021/2/22)この記事を見たのですが、このリジェクトはやばいらしいです笑
デベロッパーが最も怖れる最恐のリジェクト「4.2 Minimum Functionality」とは

これはなにか
一言で言うと「これはク○アプリですね」ということです。

自覚はありましたが、初心者には厳しい現実です。アプリをとりあえず出して改善を重ねていこうと思っていましたが、最初のリリースでもある程度のレベルは求められるということなんですね。
上の記事でもあるように、このリジェクトはレビュワーさんの主観も入っているそうなので、デザイン面も気をつけなければいけないと考えています。

まとめ

アプリは作りさえすれば簡単にAppStoreに公開されると思っていましたが、そんなことはなかったです。
当たり前のことに気づけたことが今回の収穫かと思います。
今後はAppStoreへのアプリの公開を目標に頑張っていきたいです。

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

【いったん頓挫】初心者がApp StoreにiOS自作アプリを公開する過程

なぜ書くのか

つまずいたポイントを記録するため。
僕と同じように、初めてApp Storeに自作アプリを公開しようとしている人の参考にしてもらうため。

主に以下の記事を参考にさせていただいており、適宜参照します。
【2020年度版】 iOSアプリをApp Storeに公開する手順を簡潔に解説

つまずいたポイント

プライバシーポリシー用のサイト作成

つまずいたのは、github Pagesに作成したはずのプライバシポリシーが表示されないという内容です。
表示されない原因は、プライバシーポリシーが書いてあるhtmlファイルの名前が「index.html」で無かったことでした。

プライバシーポリシーは、テンプレート作成サイト(App Privacy Policy Generator)で作成し、公開はgithub Pagesで行いました。
完成したプライバシーポリシー以下URLから見ることができます。
プライバシーポリシー

参考にした記事は以下の二つです。
個人開発者がAppStore用にプライバシーポリシーを書く
iOSでプラポリが必須になったのでGitHub Pagesで対応した

App Developer Programの登録に時間がかかるということ

iOSアプリをApp Storeに公開するためにはApp Developer Programの登録が必要ですが、登録料金(税別11800円)を支払ってからいくらか時間が必要です。登録と審査への提出をそのままやるつもりでしたが、できないようです。

約48時間後、登録完了のメールが届きました。

iOSアプリ審査の指摘事項

審査請求後、これも約48時間後にレビュー結果が送られてきました。
3つ指摘事項がありました。

Guideline 1.5 - Safety - Developer Information

(2021/2/22)サポートURLが不適切なようです。twitterのURLを提出したのですが、リジェクトされてしましました。以下の記事ではアプリのスクリーンショットと連絡先を明記すれば良いとのことですが。しかし、以下の記事では自分のブログを持っており、そこにサポートURL用のページを追加することで対処されていました。私は個人ブログを持ち合わせていないので、以下のツイートのURLで審査が通るか試してみます。

スクリーンショット 2021-03-03 21.51.43.png

【AppStore】Guideline 1.5でリジェクトされた時の対処法

Guideline 2.3.7 - Performance - Accurate Metadata

(2021/2/22)タイトルが不適切なようです。以下の記事に3つ以上のキーワードが入っているとリジェクトされやすいと書いてありましたので、減らしました。

訂正前
AtamaConcrete 台湾中国語、日本語勉強アプリ

訂正後
AtamaConcrete 日台言語学習アプリ

【AppStore】アプリ名におけるキーワードについて

Guideline 4.2 - Design - Minimum Functionality

(2021/2/22)この記事を見たのですが、このリジェクトはやばいらしいです笑
デベロッパーが最も怖れる最恐のリジェクト「4.2 Minimum Functionality」とは

これはなにか
一言で言うと「これはク○アプリですね」ということです。

自覚はありましたが、初心者には厳しい現実です。アプリをとりあえず出して改善を重ねていこうと思っていましたが、最初のリリースでもある程度のレベルは求められるということなんですね。
上の記事でもあるように、このリジェクトはレビュワーさんの主観も入っているそうなので、デザイン面も気をつけなければいけないと考えています。

まとめ

アプリは作りさえすれば簡単にAppStoreに公開されると思っていましたが、そんなことはなかったです。
当たり前のことに気づけたことが今回の収穫かと思います。
今後はAppStoreへのアプリの公開を目標に頑張っていきたいです。

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

8日目プログレスバーを使ったタイマーアプリ。

8日目のアプリ

プログレスバーを使ったタイマーアプリ。

画面キャプチャ

ボタンを3つおいて、各ボタンを押すと、ボタン名が表示される。
押すボタンによって、トータル時間が変わって、
経過時間を下のプログレスバーで表示。
プログレスバーが最後になると、音が出る。
という仕様にしました。

以下の流れで作りました。

  1. オブジェクトを配置して、Viewcontrolerに紐付け
  2. プログレスバーを追加
  3. import AVFoundation
  4. 再生するプレイヤーを作って、取り込んだ音を再生

できたこと

  • プログレスバーの設置
  • 音の再生(復習)
  • ##書いたコードを共有します!
import UIKit
import AVFoundation //音声ファイルを使うときimportする

class ViewController: UIViewController {
    @IBOutlet weak var displayText: UILabel!


    let eggTime = [
        "Soft": 5,
        "Medium": 7,
        "Hard": 12
    ]

    var myTimer = Timer()
    var totalTime = 0
    var secondsPassed = 0
    var player : AVAudioPlayer! // 音声ファイルを再生するデッキ player を作る



    @IBOutlet weak var progressBar: UIProgressView!


    @IBAction func hardnessSelected(_ sender: UIButton) {

        let selected = sender.currentTitle!
//        switch selected {
//        case "Soft":
//            print(eggTime["Soft"]!)
//        case "Medium":
//            print(eggTime["Medium"]!)
//        case "Hard":
//            print(eggTime["Hard"]!)
//
//        default:
//            print("none")
//        }
        myTimer.invalidate()
        secondsPassed = 0

        totalTime = eggTime[selected]!
        displayText.text = "\(sender.currentTitle!)"
        progressBar.progress = 0.0

        myTimer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(action), userInfo: nil, repeats: true)

    }

    @objc func action() {
        if secondsPassed < totalTime {
            secondsPassed += 1
            progressBar.progress = Float(secondsPassed) / Float(totalTime)

            print(Float(secondsPassed) / Float(totalTime))
        print(secondsPassed)
        } else {
//            progressBar.progress = 1.0
            myTimer.invalidate()
            displayText.text = "done!"
            //再生デッキに入れるCDを作る
            let url = Bundle.main.url(forResource: "alarm_sound", withExtension: "mp3")
            //再生デッキに入れる
            player = try! AVAudioPlayer(contentsOf: url!)
            //デッキの再生ボタンを押す
            player.play()

        }
        }

}

感想

よし、できた。きっとw

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

CELLをスワイプして消去する。

前提として
文字列型の配列変数todoListを定義し
textFieldで文字を代入し
cellを構築済みとする。
スクリーンショット 2021-03-03 1.49.04.png

■UITableViewDelegate
のデリゲートメソッドを使用する。

♪追加したcellを画面上でスワイプして消去する事ができる。
下記デリゲートメソッドを記述する。
スクリーンショット 2021-03-03 1.43.33.png

■なすを左にスワイプ
右側にDdeleteが表示される

スクリーンショット 2021-03-03 1.49.15.png

■Delieteを押すとなすが消去される。

スクリーンショット 2021-03-03 1.49.36.png

簡単になりますが以上です。
あくまで画面上での消去になります。
DBなどの使用の場合は別途処理が必要になります。

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

indexPath row とは

indexPath.row  選択した行を!!

indexPathで 選択した物

rowで    行!

■自分が求めているシンプルな記事がなかったので
載せときます。  

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