20201119のSwiftに関する記事は8件です。

FloatingPanelとmapのピンの連携を実装する方法

FLoatingPanelとピンの連携の目的

  • ピンを選択するとそのデータに関する情報をFloatingPanelで表示する
  • 選択中のピンのサイズを大きくする(選択中のピンを明確にするため)

手順

  1. GoogleMapsのAPIでアプリ上にGoogleMapsを表示
  2. GoogleMapsSDKで地図上にピンを表示
  3. FLoatingPanelの実装
    ここまでが準備段階です。
    ここまでは僕の使った記事の推薦で進めてきます。

  4. ピンが押された時にFLoatingPanelを表示させる

  5. FloatingPanelとの関係でピンのサイズを変化させる

1. GoogleMapsのAPIでアプリ上にGoogleMapsを表示

https://developers.google.com/ios?hl=ja

この公式の記事に従ってAPIを取得し、自分のソースコードの中に書いていきましょう

2. GoogleMapsSDKで地図上にピンを表示

https://developers.google.com/maps/documentation/ios-sdk/marker
こちらを参考にしましょう。これはGoogleMapsSDKのmarkerのところです。

3. FLoatingPanelの実装

FloatingPanelは記事がとても少ないです。

https://qiita.com/dotrikun/items/369f5c0730f444d97cf1

こちらを参考にさせてもらいました。

4. ピンが押された時にFLoatingPanelを表示させる

ここでのアクションプラン

  • GoogleMapsSDKのプロパティであるdidtap markerを書く
  • didtap markerのなかに 3. で書いたFLoatingPanelを実装する

https://developers.google.com/maps/documentation/ios-sdk/reference/protocol_g_m_s_map_view_delegate-p#a9f226252840c79a996df402da9eec235

のなかの
demo

のメソッドを書いていきます。

5. FloatingPanelとの関係でピンのサイズを変化させる

ここでのアクションプランは

  • FloatingPanelの表示を一つにする(一つにしなかったらFloatingPanelが重なるから)
  • 選択中のピンだけを大きくする
  • FloatingPanelが下スワイプで消されて時、選択されていたピンの大きさを元に戻す

FloatingPanelの表示を一つにする
これからソースコード共有して説明していきます。

qiita.swift
if fpc != nil {
    fpc.removePanelFromParent(animated: true)
    fpc = nil
}

これをFloatingPanel生成の前に書きます。
FloatingPanel生成の前に書くことによって、一つのFloatingPanelだけが表示されるようになります。

  • 選択中のピンだけを大きくする

これについては

  • 選択したピンをselected_markerというように変数に代入する
  • selected_markerにサイズを指定する 僕が書いたコードをです。

があります。

qiita.swift
 if ( selected_marker != nil) {
     selected_marker.icon = self.imageWithImage(image: UIImage(named: "pin")!, scaledToSize: CGSize(width: 32.0, height: 37.0))
            selected_marker = marker
 }
 else {
    selected_marker = marker
 }

 marker.icon = self.imageWithImage(image: UIImage(named: "pin")!, scaledToSize: CGSize(width: 42.0, height: 47.0))

ここで出てくるimageWithImageでメソッドでサイズ指定をしているのでそのメソッドのコードも載せます。

qiita.swift
func imageWithImage(image: UIImage, scaledToSize newSize:CGSize) -> UIImage {
    UIGraphicsBeginImageContextWithOptions(newSize, false, 0)
    image.draw(in: CGRect(x: 0, y:0, width: newSize.width, height: newSize.height))
    let newImage: UIImage = UIGraphicsGetImageFromCurrentImageContext()!
    UIGraphicsEndImageContext()
    return newImage
}

このメソッドのおかげでピンのサイズ指定ができるようになります。
参考にした記事はこれです。

https://qiita.com/akidon0000/items/bf67f052179c1f704699

  • FloatingPanelが下スワイプで消されて時、選択されていたピンの大きさを元に戻す これは記事が一つもなくてとても苦労しました。
qiita.swift
func floatingPanelWillRemove(_ fpc: FloatingPanelController) {
    selected_marker.icon = self.imageWithImage(image: UIImage(named: "pin")!, scaledToSize: CGSize(width: 32.0, height: 37.0))
}

初めはfloatingpPanelDidRemoveメソッドを書いていたのですがこれでは呼ばれるのがトリガーが起こってすぐではないので挙動がおかしくなりました。
floatingPanelWillRemoveを使うとすぐ呼ばれるようになり、うまくいくようになりました。
最後にどのようになるかを動画を載せます。

demo
このように選択したピンだけ大きくなります。

demo
FLoatingPanelを下スワイプで消すと選択中のピンも元のサイズに戻ります。

以上です。Lostというアプリを作ったのでどうか見てください!
僕のreadmeです!

https://github.com/kai-nakao/Lost/blob/master/README.md

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

【第4回】初心者二人で0から麻雀アプリ開発

第3回の記事はこちら

改版履歴

2020年11月19日投稿

第4回会議(2020年11月13日)

議題

・画面デザイン詰める
・実装してみたい機能
・来週からの活動
・プロジェクト全体の大まかな期間

画面デザイン

画面のデザインはgoogledrive内のdraw.ioを使用して作りました。この時、ホーム画面から成績入力画面に移動する際に、画面全体で遷移させるのか、画面を部分的に遷移させるのか悩みました。部分的に遷移するのが今の流行ということなので、部分遷移でいこう!ということになりました。また、ポップアップで入力画面を立ち上げるという案も出ました。

実装してみたい機能

今回の会議で、お互いにやってみたいことが結構出てきました。
・テーマ
最近のアプリはダークテーマとか、ホワイトテーマとか自分の好みで切り替えられるようにしたい。デフォルトであるらしい…❓

・githubの芝みたいなもの
打数に応じてカレンダーに色づけするみたいなことができればモチベーションの維持につながるのではないかと思い実装してみたいと思いました。

来週からの活動

今回から、各々テーマを決めて、次の会議までに実装の方法を調べてくるという方法をとります。
今週のテーマはそれぞれ
・テーブル表示の実装について   
・ヘッダーとフッターの実装について
・スコア表機能の実装 
ということになりました。

プロジェクトの展望と今後の予定について

要件定義と基本設計 1ヶ月

画面遷移図の作成 2ヶ月くらい

詳細設計 1ヶ月 (勉強会)、クラス図作成、処理の順番、変数名など

実装1ヶ月 (ソースコードを書く)
テスト1ヶ月半 
アプリをリリース
という大雑把な計画を立てました。
できるだけ多くの機能を実装してみたいと思っていますが、最低限挫折しないということを目標に頑張っていきます。

終わりに

雑記みたいな感じになってしまいました。速くqiitaっぽくcode載せたいです。(切実)

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

キーボードが表示されたときに UITextField を上にスクロールさせる方法

はじめに

よくある入力フォームで、テキストフィールドをタップしたときには、キーボードに隠れないように、自動的にテキストフィールドが上にスクロールしてほしいですよね。
コーディングしなくても勝手にキーボードを回避してくれる機能はSwiftにはありませんので、自分で実装する必要があります。
世の中いろいろサンプルが出回っていますが、自分なりにようやく書けるようになったので、ここにまとめます。

当記事のコードの特徴

  • テキストフィールドにタッチすると、
    • キーボードに隠れない場合は、何も起きません。
    • キーボードに隠れる場合は、テキストフィールドがキーボードの上辺に合わせてスクロールします。
  • ユーザーが入力を完了すると、キーボードが下がるのに合わせて、テキストフィールドが元の位置にスクロールします。
  • 対象のテキストフィールドがアクティブなときに、キーボードツールバーに上/下ボタンや完了ボタンを設定します。

実装イメージ

keyboardupdownsample.gif

コードの説明

ここで説明するコードは、以下のリポジトリで公開しています。
https://github.com/mnaruse/KeyboardUpDownSample

Storyboard での注意点

UITextFieldを、UIScrollViewの中に設置しておく必要があります。

IBOutlet での注意点

大抵の場合、複数のUITextFieldが置いてあると思いますので、[UITextField] というUITextFieldsの配列を作っておきます。

@IBOutlet private var textFields: [UITextField]!

ViewControllerのファイルと、Storyboardのファイルを並べて開き、このコード上のIBOutletから、Storyboard内の対象のUITextFieldに向かって、接続していきます。
最終的にこんな感じになります。

iboutlet_textfields.png

キーボードのサイズが変化すると実行されるイベントハンドラー のコードのポイント

キーボードのサイズが変化すると実行される関数について、ポイントを説明します。

/// キーボードのサイズが変化すると実行されるイベントハンドラー
/// テキストフィールドが隠れたならスクロールする。
///
/// キーボードの退場でも同じイベントが発生するので、編集中のテキストフィールドがnilの時は処理を中断する。
@objc private func keyboardChangeFrame(_ notification: Notification) {
    // 編集中のテキストフィールドがnilの時は処理を中断する。
    guard let textField = editingTextField else {
        return
    }

    // キーボードのframeを調べる。
    let userInfo = notification.userInfo
    let keyboardFrame = (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as!
NSValue).cgRectValue

以下のように、編集中のUITextFieldのframeを、キーボードのframeと同じ座標系にするところがポイントです。
これによって、重なり具合の比較が容易になります。

    // テキストフィールドのframeをキーボードと同じウィンドウの座標系にする。
    guard let textFieldFrame = view.window?.convert(textField.frame, from: textField.superview)
else {
        return
    }

次に、以下のように、編集中のテキストフィールド足す余白分(以下の例では8ptに設定しています)が、キーボードと重なっていないか調べて、それをスクロールするかしないかの判断基準にするところもポイントです。

  • 重なっていなかったら、何もせず処理を終了します。
  • 重なっていたら、テキストフィールドをキーボードの上辺に合わせて上にスクロールします。
    /// テキストフィールドとキーボードの間の余白(自由に変更してください。)
    let spaceBetweenTextFieldAndKeyboard: CGFloat = 8
    // 編集中のテキストフィールドがキーボードと重なっていないか調べる。
    // 重なり = (テキストフィールドの下端 + 余白) - キーボードの上端
    var overlap = (textFieldFrame.maxY + spaceBetweenTextFieldAndKeyboard) - keyboardFrame.minY
    if overlap > 0 {
        // 重なっている場合、キーボードが隠れている分だけスクロールする。
        overlap = overlap + scrollView.contentOffset.y
        scrollView.setContentOffset(CGPoint(x: 0, y: overlap), animated: true)
    }
}

コード全体

コードの全体は以下の通りです。

なお、コード内で実装するかしないか任意な点は以下の通りです。

  • 対象のテキストフィールドがアクティブなとき、キーボードのツールバーに、上下ボタンや完了ボタンを設定するかどうかは任意です。
  • ビューがタップされた時に、キーボードを下げるかどうかは任意です。

コード全体

//
//  ViewController.swift
//  KeyboardUpDownSample
//
//  Created by Miharu Naruse on 2020/11/15.
//

import UIKit

class ViewController: UIViewController {
    @IBOutlet private var scrollView: UIScrollView!
    @IBOutlet private var textFields: [UITextField]!

    /// 編集中のテキストフィールド
    private var editingTextField: UITextField?

    /// キーボードが登場する前のスクロール量
    private var lastOffsetY: CGFloat = 0.0

    override func viewDidLoad() {
        super.viewDidLoad()

        // 全てのテキストフィールドのデリゲートになる。
        for textField in textFields {
            textField.delegate = self
        }
        // テキストフィールドとキーボード関連の処理について、通知センターの設定をする。
        setNotificationCenter()

        // 任意: 対象のテキストフィールドがアクティブなとき、キーボードのツールバーに、前後ボタンや完了ボタンを設定する。
        addPreviousNextableDoneButtonOnKeyboard(textFields: textFields, previousNextable: true)
    }

    /// 任意: ビューがタップされた時に実行される処理
    @IBAction func tapView(_ sender: UITapGestureRecognizer) {
        // キーボードを下げる。
        view.endEditing(true)
    }
}

// MARK: - Extensions テキストフィールドとキーボード関連の処理

extension ViewController {
    /// テキストフィールドとキーボード関連の処理について、通知センターの設定をする。
    private func setNotificationCenter() {
        /// デフォルトの通知センターを取得
        let notification = NotificationCenter.default

        // キーボードのframeが変化した時のイベントハンドラーを登録する。
        notification.addObserver(self, selector: #selector(keyboardChangeFrame(_:)), name: UIResponder.keyboardDidChangeFrameNotification, object: nil)

        // キーボードが登場する時のイベントハンドラーを登録する。
        notification.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)

        // キーボードが退場する時のイベントハンドラーを登録する。
        notification.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
    }

    /// キーボードのサイズが変化すると実行されるイベントハンドラー
    /// テキストフィールドが隠れたならスクロールする。
    ///
    /// キーボードの退場でも同じイベントが発生するので、編集中のテキストフィールドがnilの時は処理を中断する。
    @objc private func keyboardChangeFrame(_ notification: Notification) {
        // 編集中のテキストフィールドがnilの時は処理を中断する。
        guard let textField = editingTextField else {
            return
        }

        // キーボードのframeを調べる。
        let userInfo = notification.userInfo
        let keyboardFrame = (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue

        // テキストフィールドのframeをキーボードと同じウィンドウの座標系にする。
        guard let textFieldFrame = view.window?.convert(textField.frame, from: textField.superview) else {
            return
        }

        /// テキストフィールドとキーボードの間の余白(自由に変更してください。)
        let spaceBetweenTextFieldAndKeyboard: CGFloat = 8

        // 編集中のテキストフィールドがキーボードと重なっていないか調べる。
        // 重なり = (テキストフィールドの下端 + 余白) - キーボードの上端
        var overlap = (textFieldFrame.maxY + spaceBetweenTextFieldAndKeyboard) - keyboardFrame.minY
        if overlap > 0 {
            // 重なっている場合、キーボードが隠れている分だけスクロールする。
            overlap = overlap + scrollView.contentOffset.y
            scrollView.setContentOffset(CGPoint(x: 0, y: overlap), animated: true)
        }
    }

    /// キーボードが登場する通知を受けると実行されるイベントハンドラー
    @objc private func keyboardWillShow(_ notification: Notification) {
        // キーボードが登場する前のスクロール量を保存しておく。
        lastOffsetY = scrollView.contentOffset.y
    }

    /// キーボードが退場する通知を受けると実行されるイベントハンドラー
    @objc private func keyboardWillHide(_ notification: Notification) {
        // スクロール量をキーボードが登場する前の位置に戻す。
        scrollView.setContentOffset(CGPoint(x: 0, y: lastOffsetY), animated: true)
    }
}

// MARK: - Extensions UITextFieldDelegate テキストフィールドとキーボード関連の処理

extension ViewController: UITextFieldDelegate {
    func textFieldDidBeginEditing(_ textField: UITextField) {
        // テキストフィールドの編集が開始された時に実行される処理。
        // どのテキストフィールドが編集中か保存しておく。
        editingTextField = textField
    }

    func textFieldDidEndEditing(_ textField: UITextField) {
        // テキストフィールドの編集が終了した時に実行される処理。
        // 編集中のテキストフィールドをnilにする。
        editingTextField = nil
    }

    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        // 改行キーが入力された時に実行される処理。
        // キーボードを下げる。
        view.endEditing(true)
        // 改行コードは入力しない。
        return false
    }
}

おわりに

読んでいただきありがとうございました。
何か少しでもお役に立てたら嬉しいです:pray:
「こういうやり方もあるよ」とか「ここはこうした方がいいよ」とか、コメントやアドバイス、あるいは質問があれば、よかったらコメントお願いします。

関連記事

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

XGBoostをiOSアプリで動作させる

Kaggleなどのコンペで人気のXGBoostをiOSアプリで動作させたので、その方法を紹介します。

XGBoostとは

勾配ブースティング決定木 (GBDT, Gradient Boosting Decision Tree)を実装したライブラリです。

GBDTとは

勾配ブースティング決定木とは、手短に説明すると『決定木を複数組み合わせたアンサンブル学習の一種で、勾配降下法を用いて学習を行うもの』です。

決定木とは、木構造を用いて回帰や分類を行う手法です。アンサンブル学習は、複数の決定木を用いて予想の精度を上げる手法です。

ブースティングというのは、アンサンブル学習の一種で、それぞれの学習器を直列に学習する手法です。それ以外のアンサンブル学習の手法としては学習器を並列に学習させるバギングなどがあります。例えば、ランダムフォレストはバギングの一種です。

勾配降下法とは、ディープラーニングでもよく用いられる手法で、重みを少しずつ更新して勾配が最小になるように学習する手法です。

図で説明します

駆け足過ぎたので、図で説明します。

例えば、決定木は下の図のようにして、その人がゲーム好きかどうかを予想します。

【決定木の例】

ブースティングを用いた場合は、複数の決定木を用いて予想値を修正していきます。

【ブースティング決定木の例】

最初の木で出したポイントと、2番目の木で出したポイントを足して、最終的なポイントを出しています。ここではポイントまでしか出していませんが、最終的にはこれを確率に変換します。

なぜこのようにすると予想の精度が上がるのか?についてはこちらに解説がありました。

Kaggle Masterが勾配ブースティングを解説する

学習は最初の木を学習させたあと、その予想結果との差に対して新たな木を作って学習させます。このように順番に学習していくので、「直列に学習する」と表現してます。

また、予想結果の差に対して新たに学習していくので、勾配に従って学習していくことになります。これが勾配降下法に相当します。

学習についてはこちらのブログがわかりやすかったです。

GBDTの仕組みと手順を図と具体例で直感的に理解する

XGBoostを使ってみる

Google Colabを使ってXGBoostを試してみます。ほぼこちらのブログの内容の写経になっています。

Python: XGBoost を使ってみる - 乳がんデータセットを分類してみる

乳がんデータセットを使って、特徴量から乳がんであるかを予想する二値分類問題です。

Notebook
import xgboost as xgb

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from matplotlib import pyplot as plt

dataset = datasets.load_breast_cancer()
X, y = dataset.data, dataset.target

X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        test_size=0.3,
                                                        shuffle=True,
                                                        random_state=42,
                                                        stratify=y)

clf = xgb.XGBClassifier(objective='binary:logistic',
                            n_estimators=1000)

evals_result = {}
clf.fit(X_train, y_train,
      eval_metric='logloss',
      eval_set=[
          (X_train, y_train),
          (X_test, y_test),
      ],
      early_stopping_rounds=10,
      callbacks=[
          xgb.callback.record_evaluation(evals_result)
      ],
)

y_pred = clf.predict(X_test)
acc = accuracy_score(y_test, y_pred)
print('Accuracy:', acc)

# 出力結果
# Accuracy: 0.9649122807017544

Core MLに変換する

モデルができたので、今度はCore MLに変換します。
Core ML ToolsはXGBoostの変換に対応しているので、簡単に変換することができます。

Notebook
pip install -U coremltools==4.0

import coremltools as ct

coreml_model = ct.converters.xgboost.convert(clf , mode='classifier')

coreml_model.save('xgboost_test.mlmodel')

iOSアプリで動作させる

iOSアプリで動作させます。Core MLを簡単に試せるリポジトリを作ったのでこれを使います。

TokyoYoshida/CoreMLSimpleTest

このリポジトリをCloneして、変換したモデルをドラッグ & ドロップします。

モデルの情報はこんな感じです。


早速予想してみましょう。
モデルに入力する情報(特徴量)が30もあるのでちょっと大変ですが、Google Colabでダウンロードしたデータセットから取得して、xgboost_testInputの初期化パラメータに与えます。

ViewController.swift
    @IBAction func buttonSimpleMLTapped(_ sender: Any) {
        let model = xgboost_test()
        let inputToModel: xgboost_testInput = xgboost_testInput(
            f0: 1.300e+01,
            f1: 2.078e+01,
            f2: 8.351e+01,
            f3: 5.194e+02,
            f4: 1.135e-01,
            f5: 7.589e-02,
            f6: 3.136e-02,
            f7: 2.645e-02,
            f8: 2.540e-01,
            f9: 6.087e-02,
            f10: 4.202e-01,
            f11: 1.322e+00,
            f12: 2.873e+00,
            f13: 3.478e+01,
            f14: 7.017e-03,
            f15: 1.142e-02,
            f16: 1.949e-02,
            f17: 1.153e-02,
            f18: 2.951e-02,
            f19: 1.533e-03,
            f20: 1.416e+01,
            f21: 2.411e+01,
            f22: 9.082e+01,
            f23: 6.167e+02,
            f24: 1.297e-01,
            f25: 1.105e-01,
            f26: 8.112e-02,
            f27: 6.296e-02,
            f28: 3.196e-01,
            f29: 6.435e-02
        )
        if let prediction = try? model.prediction(input: inputToModel) {
            print(prediction.target)
            print(prediction.classProbability)
        }
    }

アプリを実行するとボタンが複数出てくるので一番上のボタンを押します。

ボタンを押すと、Xcodeのコンソールに予想結果が出力されます。

コンソール出力
1
[0: 0.019117622730040473, 1: 0.9808823772699595]

うまく分類できているようです。

最後に

2020年11月現在、XGBoostをiOSで動かしたい人はほとんどいないのか、ネット上にはほとんど情報がありません。

また、Core ML Toolsのドキュメントもあまり親切ではないので、もしXGBoostをiOSで動かしたいという方の参考になりましたら幸いです。


NoteではiOS開発、とくにCoreML、ARKit、Metalなどについて定期的に発信していますので、フォローしていただけますと幸いです。
https://note.com/tokyoyoshida

Twitterでも発信しています。
https://twitter.com/jugemjugemjugem

参考資料

勾配ブースティング決定木についてはこちらのブログの説明が詳しかったです。

勾配ブースティング決定木ってなんぞや

ブースティングについては上のブログにはあまり説明がなく、こちらの資料がわかりやすかったです。

ブースティング入門

GBDTと勾配降下法の関係については、こちらのブログが参考になりました。

なんでXGBoostは分類問題の学習に対数損失を使うんですか?

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

[Swift][MVVM]関数型ViewModelのススメ

概要

本稿ではアプリアーキテクチャの1形式であるMVVMの、特にViewControllerViewModelの関連を記述するスタイルとして関数型ViewModelを紹介します。

参考リンク: Modeling Your View Models as Functions

前提知識

本稿を読む前提として、以下の知識があると理解しやすいです。
- iOSアプリ開発
- Swift 5
- RxSwift

MVVMを使ったアプリの実装例

いくつかのスタイルで簡単な機能のアプリを実装してみます。

仕様

まず、以下のようなアプリを考えてみましょう

  • 画面の中央にカウンターを表示する
  • +ボタンを押すとカウンターの数値が1増える
  • -ボタンを押すとカウンターの数値が1減る
  • 数値が偶数ならカウンターの文字色が黒に、奇数ならになる。

画面レイアウト

まずレイアウトを実装します。この時点ではボタンを押しても何も反応しません。

import UIKit

final class ViewController: UIViewController {
    private var countLabel: UILabel! // 合計カウント表示ラベル
    private var plusButton: UIButton! // +ボタン
    private var minusButton: UIButton! // -ボタン

    override func loadView() {
        super.loadView()
        view.backgroundColor = .white

        countLabel = UILabel()
        view.addSubview(countLabel)
        countLabel.translatesAutoresizingMaskIntoConstraints = false
        countLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        countLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true


        plusButton = UIButton(type: .system)
        plusButton.setTitle("+", for: .normal)
        view.addSubview(plusButton)
        plusButton.translatesAutoresizingMaskIntoConstraints = false
        plusButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        plusButton.bottomAnchor.constraint(equalTo: countLabel.topAnchor, constant: -10).isActive = true


        minusButton = UIButton(type: .system)
        minusButton.setTitle("-", for: .normal)
        view.addSubview(minusButton)
        minusButton.translatesAutoresizingMaskIntoConstraints = false
        minusButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        minusButton.topAnchor.constraint(equalTo: countLabel.bottomAnchor, constant: 10).isActive = true
    }

ViewControllerによる実装例

このアプリを、愚直にViewControllerだけで実装してみましょう。コードは以下のとおりです。

import UIKit

final class ViewController: UIViewController {
    // レイアウトは省略
    private var count: Int = 0

    override func viewDidLoad() {
        super.viewDidLoad()
        plusButton.addTarget(self, action: #selector(plusButtonDidTap), for: .touchUpInside)
        minusButton.addTarget(self, action: #selector(minusButtonDidTap), for: .touchUpInside)
        updateCountLabel()
    }

    @objc func plusButtonDidTap() {
        count += 1
        updateCountLabel()
    }

    @objc func minusButtonDidTap() {
        count -= 1
        updateCountLabel()
    }

    private func updateCountLabel() {
        countLabel.text = "\(count)"
        countLabel.textColor = count %2 ==0
            ? UIColor.black
            : UIColor.red
    }
}

MVVM(Kickstarterスタイル)による実装例

https://github.com/kickstarter/ios-oss (Kickstarter社のリポジトリ)
https://qiita.com/muukii/items/045b12405f7acff1a9fd (KickstarterスタイルのMVVMについての解説記事)

こちらのリンクを参考に、同じ機能を実装してみましょう。

ViewModelは以下のように実装します。

import RxSwift
import RxCocoa

protocol ViewModelInputs {
    func plusButtonDidTap()
    func minusButtonDidTap()
}

protocol ViewModelOutputs {
    var count: Driver<Int> { get }
    var isEven: Driver<Bool> { get }
}

protocol ViewModelType {
    var inputs: ViewModelInputs { get }
    var outputs: ViewModelOutputs { get }
}

struct ViewModel: ViewModelType, ViewModelInputs, ViewModelOutputs {

    var inputs: ViewModelInputs { return self }
    var outputs: ViewModelOutputs { return self }

    let count: Driver<Int>
    let isEven: Driver<Bool>

    private let plusButtonDidTapProperty = PublishSubject<Void>()
    private let minusButtonDidTapProperty = PublishSubject<Void>()

    init() {
        count = Observable.merge(plusButtonDidTapProperty.map { _ in 1 }, // +ボタンタップは1に変換する
                                 minusButtonDidTapProperty.map { _ in -1 }) // -ボタンタップは-1に変換する
            .scan(0, accumulator: +) // 0から開始して今までの合計値を出力する
            .startWith(0) // 初期値は0
            .asDriver(onErrorDriveWith: .never())

        isEven = count
            .map { $0 % 2 == 0 }
    }

    func plusButtonDidTap() { plusButtonDidTapProperty.onNext(()) }
    func minusButtonDidTap() { minusButtonDidTapProperty.onNext(()) }
}

ViewControllerは以下のように実装します。

import RxSwift
import RxCocoa

final class ViewController: UIViewController {
    // レイアウトは省略
    private let viewModel: ViewModelType
    private let disposeBag = DisposeBag()

    init(viewModel: ViewModelType = ViewModel()) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        plusButton.rx.tap
            .subscribe(onNext: { [viewModel] in viewModel.inputs.plusButtonDidTap() })
            .disposed(by: disposeBag)

        minusButton.rx.tap
            .subscribe(onNext: { [viewModel] in viewModel.inputs.minusButtonDidTap() })
            .disposed(by: disposeBag)

        viewModel.outputs
            .count
            .map { "\($0)" }
            .drive(countLabel.rx.text)
            .disposed(by: disposeBag)

        viewModel.outputs
            .isEven
            .map { $0 ? UIColor.black : UIColor.red }
            .drive(onNext: { [countLabel] in countLabel?.textColor = $0 })
            .disposed(by: disposeBag)
    }
}

国内の資料でiOS・Swift環境でMVVMというと、だいたいはこのようなKickstarterスタイルのMVVMが多い印象を受けます。
RxSwiftを使用するテンプレートとしては優れていますが、以下のような問題点があります。

  • Inputs,Outputsと宣言するプロトコルがやや多い。
  • InputsPropertyとの対応関係の記述が冗長。
  • 使用されないOutputs変数があってもコンパイラに警告されない。

MVVM(関数型スタイル)による実装例

https://medium.com/grailed-engineering/modeling-your-view-models-as-functions-65b58525717f

こちらの記事を参考に、関数型スタイルのMVVMを実装してみましょう。

ViewModelは以下のように実装します。リンク先の記事とは違いSwift5.2で追加されたcallAsFunctionを使っています。

import RxSwift
import RxCocoa

protocol ViewModelType {
    func callAsFunction(
        plusButtonDidTap: Observable<Void>,
        minusButtonDidTap: Observable<Void>
    ) -> (
        count: Driver<Int>,
        isEven: Driver<Bool>
    )
}

struct ViewModel: ViewModelType {
    func callAsFunction(
        plusButtonDidTap: Observable<Void>,
        minusButtonDidTap: Observable<Void>
    ) -> (
        count: Driver<Int>,
        isEven: Driver<Bool>
    ) {
        let count = Observable.merge(plusButtonDidTapProperty.map { _ in 1 }, // +ボタンタップは1に変換する
                                 minusButtonDidTapProperty.map { _ in -1 } ) // -ボタンタップは-1に変換する
            .scan(0, accumulator: +) // 0から開始して今までの合計値を出力する
            .startWith(0) // 初期値は0
            .asDriver(onErrorDriveWith: .never())

        let isEven = count
            .map { $0 % 2 == 0 }

        return (
            count,
            isEven
        )
    }
}

ViewControllerは以下のように実装します。

import RxSwift
import RxCocoa

final class ViewController: UIViewController {
    private let viewModel: ViewModelType
    private let disposeBag = DisposeBag()

    init(viewModel: ViewModelType = ViewModel()) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    override func viewDidLoad() {
        super.viewDidLoad()

        let (
            count,
            isEven
        ) = viewModel(
            plusButtonDidTap: plusButton.rx.tap.asObservable(),
            minusButtonDidTap: minusButton.rx.tap.asObservable()
        )

        count
            .map { "\($0)" }
            .drive(countLabel.rx.text)
            .disposed(by: disposeBag)

        isEven
            .map { $0 ? UIColor.black : UIColor.red }
            .drive(onNext: { [countLabel] in countLabel?.textColor = $0 })
            .disposed(by: disposeBag)
    }
}

これまでのスタイルと違いViewModelの記述がシンプルになっていますね。

関数型MVVMを使うメリット・デメリット

主にKickStarterスタイルと比較した場合のメリット・デメリットが以下になります。

メリット

  • 宣言するプロトコル、メソッドが一つで済む。その他全体的な記述がシンプルになっている。
  • 一つの関数内にObservableのイベント遷移が集約されるため、実装の検証が容易。
  • ViewModelの戻り値に使用されていない変数があった場合、コンパイラが警告してくれる。

デメリット

  • KickStarterスタイルと比べてより宣言的な記述を強要するため、開発メンバーがRxSwiftに精通していることが必要とされる。
  • 一つの関数内の記述量が多いため、適切に処理を切り出さないと却って見通しが悪くなる。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[swift]選択肢をもつダイアログの後に処理に応じて再びダイアログを表示して画面遷移したい

タイトル長くなりがち。

やりたい事

アカウント設定画面でログアウトボタンを押した時に、

  • 「ログアウトしますか?」というダイアログに「OK」/「キャンセル」の選択肢を用意。

  • 「OK」を押すと、APIでログアウト処理が行われる。
    →成功すると、「ログアウトしました」というダイアログが表示され、画面が遷移する。
    →失敗すると、「ログアウトに失敗しました」というアラートが表示される。

  • 「キャンセル」を押すと、最初のダイアログが消える。

っていう処理を行いたい:hugging:

とりあえず作ってみた

今回は、

  • ちゃんと読んでほしいアラートは手動で閉じる。
  • お知らせ程度のダイアログは勝手に消える。

みたいな感じでメソッドを使い分けています。

AccountViewController.swift
import UIKit
final class AccountViewController: UIViewController {
    @IBAction private func tappedLogoutButton(_ sender: UIButton) {
        let dialog: UIAlertController = UIAlertController(title: "ログアウト",
                                                          message: "ログアウトしますか?",
                                                          preferredStyle: .alert)
        dialog.addAction(UIAlertAction(title: "OK", style: .default) { _ in
            // 本来はAPI通信の結果をresultに入れています。(今回は割愛)
            let result:Bool = true
            switch result {
            case true:
                AlertUtil.showAlert(title: "Success", message: "ログアウトに成功しました。",viewController: self)
                // 以下に画面遷移処理を追加。(今回は割愛)
            case false:
                AlertUtil.showAlert(title: "ログアウト失敗", message: "ログアウトに失敗しました。", viewController: self)
            }
            dialog.addAction(UIAlertAction(title: "閉じる", style: .cancel))
            self.present(dialog, animated: true)
        })

    }

}
AlertUtil.swift
struct AlertUtil {
    static func showDialog(title: String, message: String, viewController: UIViewController) {
        let dialog = UIAlertController(title: title,
                                       message: message,
                                       preferredStyle: .alert)
        viewController.present(dialog, animated: true) {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
                dialog.dismiss(animated: true, completion: nil)
            }
        }
    }

    static func showAlert(title: String, message: String, viewController: UIViewController) {
        DispatchQueue.main.async {
            let alert = UIAlertController(title: title,
                                          message: message,
                                          preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: "キャンセル",
                                          style: .default))
            viewController.present(alert, animated: true)
        }
    }
}

上のコードだと、

:raising_hand:
アラート関連の処理はAlertUtilにまとめちゃお!
でも、API通信の結果によってaddActionの処理を分けたいから・・・
最初のダイアログだけViewControllerで作っちゃお!

って感じ。

でも、他の部分ではアラート表示をAlertUtilで行なっているから、

なるべく統一していきたいな・・・

という事で:open_hands:

alertActionを引数にとる

スッキリ統一感のあるコードにするために、alertActionを引数にとる形に変更してみます。

AccountViewController.swift
import UIKit
final class AccountViewController: UIViewController {
    @IBAction private func tappedLogoutButton(_ sender: UIButton) {
        let alertAction: UIAlertAction = UIAlertAction(title: "OK", style: .default) { _ in
            // 本来はAPI通信の結果をresultに入れています。(今回は割愛)
            let result:Bool = true
            switch result {
            case true:
                AlertUtil.showAlert(title: "Success", message: "ログアウトに成功しました。",viewController: self)
                // 以下に画面遷移処理を追加。(今回は割愛)
            case false:
                AlertUtil.showAlert(title: "ログアウト失敗", message: "ログアウトに失敗しました。", viewController: self)
            }
        }
        AlertUtil.showChoiceDialog(title: "ログアウト", message: "ログアウトしますか?", viewController: self, alertAction: alertAction)
    }
}
AlertUtil.swift
struct AlertUtil {
    static func showDialog(title: String, message: String, viewController: UIViewController) {
        let dialog = UIAlertController(title: title,
                                       message: message,
                                       preferredStyle: .alert)
        viewController.present(dialog, animated: true) {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
                dialog.dismiss(animated: true, completion: nil)
            }
        }
    }

    static func showAlert(title: String, message: String, viewController: UIViewController) {
        DispatchQueue.main.async {
            let alert = UIAlertController(title: title,
                                          message: message,
                                          preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: "キャンセル",
                                          style: .default))
            viewController.present(alert, animated: true)
        }
    }

    // このメソッドを追加
    static func showChoiceDialog(title: String, message: String, viewController: UIViewController, alertAction: UIAlertAction) {
        let dialog: UIAlertController = UIAlertController(title: title,
                                                          message: message,
                                                          preferredStyle: .alert)
        dialog.addAction(UIAlertAction(title: "キャンセル", style: .default))
        dialog.addAction(alertAction)
        viewController.present(dialog, animated: true)
    }
}

こうする事で、

showAlert系のメソッドは全てAleretUtilで作成する事に統一し、

AccountViewController内の記述もスッキリしました:ok_woman::sparkles:

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

【SwiftUI】RPGにあるような攻撃エフェクトを再現してみたい!

はじめに

こんにちは!@kanato4です。

仕事ではJavaがメインなのですが、プライベートではSwiftを勉強している若輩者です。ただ、Swiftははじめて間もないので基本的な文法しか分かりません。これからもっと知識をつける予定です。

さて、そんなSwift初心者の私ですが、何を思ったのか「思いきってSwiftUIを使って遊んでみよう!」と考えたわけです。

SwiftUIは2019年に発表されたフレームワークなのですが、従来のSwiftの書き方とかなり違ってくるという話だったので、「やっぱこれからiPhoneアプリ作るなら知っておかないとね☆」と挑戦したわけですが、案の定苦労しました。。

今回はそんな私がはじめてSwiftUIで作れた成果物?を折角なら見てもらいたいなぁと思い記事にしました。アウトプット大事!

完成したもの

まずは完成したものを見てやってください。
Image from Gyazo
内容は簡単なもので、ボタンを押すと爆発するアニメーションが表示されます。同時に、真ん中のイラストもボタンを押す前後で変化するようにしました。もういちどボタンを押すとリセットされます。

爆発は繰り返される事なく、一回だけ爆発して終了します。(意外とここで躓いた)

正直、Swiftを学び始めて間もないのでこれだけでも結構苦労しました。。
まだまだ課題は山積みといった感じですが、とりあえず形になって良かったなという感想です。

実際のコード

実際に実装したコードがこちらになります。

ContentView.swift
import SwiftUI

struct ContentView: View {
    //ステートプロパティ
    @State private var trigger = false

    var body: some View {
        VStack {
            Text("爆破スイッチ")
                .font(.largeTitle)
            Spacer()
            ZStack{
                //三項演算子(条件式 ? true時の値 : false時の値)
                Image(self.trigger ? "computer_note_bad" :"computer_man")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 200, height: 200)
                //triggerがtrueだったら
                if trigger{
                    //構造体を呼び出す
                    LoadingView()
                }
            }
            Spacer()
            Button(action: {
                //クロージャ内ではプロパティを参照する際には自分自身を指すselfを指定
                //toggleメソッドはブール値(Bool型)の値を反転させる
                self.trigger.toggle()
            }) {
                //三項演算子(条件式 ? true時の値 : false時の値)
                Text(self.trigger ? "もういちど" : "ばくはつ!")
            }
            .font(.largeTitle)
            .foregroundColor(.red)
            .padding()
        }
    }
}

struct LoadingView: View {
    //ステートプロパティ
    @State private var index = 0
    //mapで1~16の数字に処理を適用し、その処理を施した配列imagesを作成する
    private let images = (1...16).map { UIImage(named: "explotion_\($0)")! }
    //指定された時間の間隔で現在の日時への接続を繰り返す
    private var timer = Timer.publish(every: 0.05, on: .main, in: .default).autoconnect()

    var body: some View {
       return Image(uiImage: images[index])
           .resizable()
           .scaledToFit()
           .frame(width: 300, height: 300, alignment: .center)
           //パブリッシャー(timer)によって発行されたデータを検出したときの処理
           .onReceive(
               timer,
               perform: { _ in
                   //クロージャ内ではプロパティを参照する際には自分自身を指すselfを指定
                   self.index = self.index + 1
                   if self.index >= 16 {
                       //timerの自動接続を停止
                       self.timer.upstream.connect().cancel()
                       self.index = 0
                   }
               }
           )
    }
}

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

使用した画像はこのような感じです。
Image from Gyazo
コード中のtoggleメソッドでcomputer_mancomputer_note_badを切り替えています。

爆発エフェクトの画像はexplotion_1からexplotion_16に分けています。
explotion_1には、いわゆる透明な画像が入っている状態ですね。

@State private var trigger = false

@Stateと書かれている部分はステートプロパティと呼ばれるものです。
この属性を宣言する事でSwiftUIがプロパティを自動で監視してくれて、その値が変更されると自動でビューを更新してくれる偉いやつです。ステートプロパティへのアクセスはコンテンツビューの内部からのみに限定される事が望ましいため、privateと言うアクセス修飾子を指定しています。また、privateの装飾子を指定する事で外部からアクセスができなくなるため、必ず初期化をする必要があります。

アニメーションの再現方法としては、番号が振ってある爆発の画像を変数imagesに格納し、timerが接続されるたびにindexの数字が加算されていき、indexの数字に対応した爆発の画像をViewに返すという処理を繰り返して再現しています。

また、アニメーションの処理が記述されている構造体のif文の次の1文が大事。

self.timer.upstream.connect().cancel()

この1文で接続をキャンセルしないと永遠と爆発してしまいます。
Image from Gyazo
ノートPCが可哀想ですね。。

ここで、爆発の画像の最初の1つ(explotion_1)を透明の画像にした理由なのですが、上記の1文でキャンセルするとアニメーションは停止するのですが、最初のアニメーション画像が呼び出された状態でストップしてしまいます。
Image from Gyazo
explotion_1に爆発の画像を入れると中途半端に爆発して止まってしまいます。。

そのため、最初の画像は透明にしてます。単にコードの出来がよくない可能性もありますが、やむなくこのような形にしました。無念。

おわりに

滅茶苦茶読みづらかったでしょうが、ここまでお付き合いいただきありがとうございました。

Swiftは分からないことだらけですが、きちんとアウトプットをやっていって、ゆくゆくは個人開発したものをストアで公開できるぐらいになりたいと思います。

またちょこちょこSwiftUIで遊んでみたいなぁ。

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

[iOS]音速でモーダルを実装する

できるだけコードを書かずにモーダルを実装します

バージョン

Swift 5.3
Xcode 12.1

モーダルとは

画面の下からふわっと出てくるあれです。
Wikipediaによると

コンピュータアプリケーションソフトウェアのユーザインタフェース設計において、何らかのウィンドウの子ウィンドウとして生成されるサブ要素のうち、ユーザーがそれに対して適切に応答しない限り、制御を親ウィンドウに戻さないもの。モーダルウィンドウはGUIシステムで、ユーザーに注意を促したり、選択肢を提示したり、緊急の状態を知らせたりする目的でよく使われる。

ユーザーが現在見ている画面の上に表示され、ユーザーにその瞬間に知らせたい情報を表示したり、選択肢を提示するUI」と考えたら良さそう。

実装

モーダルはコードを書かずに実装することが出来ます。Xcodeすごい!!

まず、UIViewControllerを設置して

vc.gif

次にボタンを設置します

button.gif

そしてボタンをControlを押しながらクリックし、追加したUIViewControllerまでドラッグします。
そうすると、選択肢が表示されるので、Present Modallyを選択!
modal.gif

Runすると、
ちゃんとモーダルが実装できました!

参考文献

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