20201023のSwiftに関する記事は13件です。

【Xcode 12.1】line 144: ARCHS[@]: unbound variable Command PhaseScriptExecution failed with a nonzero exit code

Xcode12.1で以下のエラーで実機ビルドとarchiveできなくなり数時間ハマった。

line 144: ARCHS[@]: unbound variable
Command PhaseScriptExecution failed with a nonzero exit code

以下の方法で解決できました。

やった

  • プロジェクトのBuildSetting -> User Defined にVALID_ARCHSを追加。値は$(ARCHS_STANDARD)
  • pod install後にPodsプロジェクトのBase SDKをmacOSからiOSに変更(macOSだとARCHS_STANDARDの値がiOSと異なるので)

やらない

以下は解決方法としてぐぐると出てくるが実施しない
- Excluded Architectureにarm64を追加する

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

[Swift] Stringの文字数がある数に一致するかどうかを知りたい時に…

はじめに

 ある文字列が空かどうか調べたい時、「string.count == 0ではなくstring.isEmptyを使いましょう」ということは聞いたことがあるかと思います。
 理由としては、Stringにおけるvar count: Intは $O(n)$ だけどvar isEmpty: Boolは $O(1)$ だからということになります1

 では、ある文字列のcountが任意の数と等しいかどうかを調べるにはどうしたらいいでしょうか。

extension String {
  func countIsEqual(to expectedCount: Int) -> Bool {
    // ここを考える
  }
}

実装例

単純、では△

いちばん簡単なのは次のようなものでしょう。

extension String {
  func countIsEqual(to expectedCount: Int) -> Bool {
    return self.count == expectedCount
  }
}

let string = "Qiita"
print(string.countIsEqual(to: 1)) // -> false
print(string.countIsEqual(to: 5)) // -> true
print(string.countIsEqual(to: 10)) // -> false

 でも、ちょっと待ってください。文字列が空かどうか調べるときにisEmptyを使うのはcountが$O(n)$だからでした。このcountIsEqualは内部でcountを呼んでいます。selfが高々数文字なら許容範囲でしょうが、selfが1000万文字とかになり得るのであれば無視できません。
 countを呼ばない方法を考えてみましょう。

前から数えていく

countを使わないとなると、地道に自分で数えるしかありません。でも自分で数えていけばexpectedCountを超えた時に数えることを打ち切ることができます。
というわけで、こうなりました:

extension String {
  func countIsEqual(to expectedCount: Int) -> Bool {
    guard expectedCount >= 0 else { return false }

    var countNow = 0
    for _ in self {
      countNow += 1
      if countNow > expectedCount {
        return false
      }
    }
    return countNow == expectedCount
  }
}

let string = "Qiita"
print(string.countIsEqual(to: 1)) // -> false
print(string.countIsEqual(to: 5)) // -> true
print(string.countIsEqual(to: 10)) // -> false

ね、簡単でしょ?

 ちなみに、これも$O(n)$ではあるのですが、この場合の $n$ は「countexpectedCountの小さい方」ということになります。基本的にcountexpectedCountがそんなに変わらないのであれば最初の単純な実装のほうでいいかもしれません。しかし、たとえば、外部からの入力などで(意図的か否かは別として)大きな文字列が入ってくる可能性があり、且つexpectedCountが小さい値をとることが期待される場合は、こちらの実装の方が有用でしょう。

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

[Swift]UIProgressView を利用して進捗状況を表示 + UIAlertController を利用して処理完了を通知

書くこと

・UIProgressView を利用して進捗状況を表示
・UIAlertController を利用して処理完了を通知

処理のイメージ

開発環境

PC MacBook Air(13-inch,2017)
PC OS macOS Catalina(ver 10.15.6)
IDE Xcode(ver 12.0.1)
iPhone SE(2nd Generation)
iPhone OS ver 14.0.1
Swift ver 5.3

前提条件

・Xcode を利用してデスクトップ上に Sample というプロジェクトアプリを作成する
・今回は、 Sample にある ViewController.swift にコードを記述する
UIProgressViewMain.storyboard 上に配置
UIProgressView は画面中央に配置されるよう制約を設定している
・下記コード例はリファクタリングを行っていない

コード例

ViewController.swift
import UIKit

class ViewController: UIViewController {
    @IBOutlet weak var progressView: UIProgressView!

    override func viewDidLoad() {

        DispatchQueue.main.asyncAfter(deadline: .now() + 1){
            self.progressView.setProgress(1/10, animated: true)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 2){
            self.progressView.setProgress(2/10, animated: true)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 3){
            self.progressView.setProgress(3/10, animated: true)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 4){
            self.progressView.setProgress(4/10, animated: true)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 5){
            self.progressView.setProgress(5/10, animated: true)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 6){
            self.progressView.setProgress(6/10, animated: true)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 7){
            self.progressView.setProgress(7/10, animated: true)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 8){
            self.progressView.setProgress(8/10, animated: true)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 9){
            self.progressView.setProgress(9/10, animated: true)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 10){
            self.progressView.setProgress(10/10, animated: true)
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + 11){
            let alert:UIAlertController = UIAlertController(title: "お知らせ", message: "処理が終了しました", preferredStyle: .alert)
            let action:UIAlertAction = UIAlertAction(title: "OK", style: .default, handler: nil)
            alert.addAction(action)
            self.present(alert, animated: true, completion: nil)
        }
    }

    override func viewWillAppear(_ animated: Bool) {
        progressView.progress = 0
    }
}

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

iOSアプリ開発:タイマーアプリ(8.プログレスバーの実装)

内容

タイマーアプリを作成するためのポイントを複数記事に分けて掲載しています。
この記事では、カウントダウンタイマーの残り時間を示す円形のプログレスバーの作成について掲載します。

環境

  • OS: macOS 10.15.7 (Catalina)
  • エディタ: Xcode 12.1
  • 言語: Swift
  • 主な使用ライブラリ: SwiftUI

手順

  1. プログレスバーの View を作成する
  2. プログレスバーの背景用の円を作成する
  3. プログレスバー用の円を作成する
  4. プログレスバーの長さを経過時間に連動させる
  5. MainView にプログレスバーを配置する
  6. プログレスバーの動きを滑らかにする

1. プログレスバーの View を作成する

ProgressBar.swift という名前で新規ファイルを作成します。この View でも TimeManager クラスからプロパティの値を参照しますので、var の前に @EnvironmentObject プロパティラッパーをつけて TimeManager クラスのインスタンスを作成しておきます。

ProgressBar.swift
import SwiftUI

struct ProgressBarView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        Text("Hello, World!")
    }
}

2. プログレスバーの背景用の円を作成する

プログレスバーは、時間経過とともに短くなっていく円と、その背景となる円の2つが必要です。背景の円は、時間が経過してもずっと同じ大きさで長さが変化しません。まず先に、背景の円から作成していきます。

body{} の中に円を配置します。SwiftUI には Circle() という図形のコンポーネントが用意されていますので、これを利用します。

ProgressBar.swift
struct ProgressBarView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        Circle()
    }
}

図形は、輪郭線と面で構成されていますので、中空の円にするには、面を表示せず、輪郭線だけ表示し、その輪郭線の太さや長さ、色を調整します。

Circle() のモディファイアを追加して望んだ形状にしていきます。

.stroke モディファイアで、引数に Color() を入れて、さらにその引数を .darkGray にして、背景らしいグレーの色にします。

.stroke モディファイアで、style 引数の lineWidth を 20 にしてプログレスバーの太さを指定します。

.scaledToFit モディファイアで、円のサイズをスクリーンサイズいっぱいに合わせ、 .padding モディファイアでスクリーン端との余白を調整します。

ProgressBar.swift
struct ProgressBarView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        Circle()
                .stroke(Color(.darkGray), style: StrokeStyle(lineWidth: 20))
                .scaledToFit()
                .padding(25)
    }
}

3. プログレスバー用の円を作成する

これから作成するプログレスバー用の円は、手順2で作成した背景用の円とレイヤー状に重なるので、プログレスバー用の Circle() コンポーネントをもう一つ追加したら、 ZStack{} で2つの円を囲います。

ProgressBar.swift
struct ProgressBarView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        ZStack {
            //背景用の円
            Circle()
                .stroke(Color(.darkGray), style: StrokeStyle(lineWidth: 20))
                .scaledToFit()
                .padding(25)

            //プログレスバー用の円
            Circle()
        }
    }
}

背景用の円と同様に、プログレスバー用の円もモディファイアを追加して形状を調整していきます。

.stroke モディファイアで、引数にColor() を入れ、プログレスバーの色をひとまず .cyan を指定しました。

.stroke モディファイアで、style 引数の StrokeStyle に細かな指定を入れます。lineWidth で幅を 20 に指定、lineCap で .round を指定して線の端の角に丸みを出し、lineJoin で .round を指定して線の端を線幅の1/2の長さで超えて丸みを出します。

.rotationEffect モディファイアを追加し、引数に Angle(degrees: -90) と入れます。これにより、デフォルトの円の輪郭線の開始位置が3時の方向であるのを、12時の方向に変更できます。

その他は背景用の円と同様です。

ProgressBar.swift
struct ProgressBarView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        ZStack {
            //背景用の円
            Circle()
                .stroke(Color(.darkGray), style: StrokeStyle(lineWidth: 20))
                .scaledToFit()
                .padding(25)

            //プログレスバー用の円
            Circle()
                .stroke(Color(.cyan), style: StrokeStyle(lineWidth: 20, lineCap: .round, lineJoin: .bevel))
                .scaledToFit()
                //輪郭線の開始位置を12時の方向にする
                .rotationEffect(Angle(degrees: -90))
                .padding(25)
        }
    }
}

4. プログレスバーの長さを経過時間に連動させる

プログレスバーの円に、さらにモディファイアを追加して、カウントダウンタイマーの経過時間に連動してプログレスバーが短くなっていくようにします。

.trim モディファイアを追加します。これにより、プログレスバーを必要な長さにトリムすることができます。

開始位置はいつでも12時の方向で固定ですので、引数 from には 0 を入れます。時間経過とともにプログレスバーは短くなっていく必要があるので、終端位置を常に残り時間と連動させる必要があります。また、引数 from も to も入れる値は 0 ~ 1 の間である必要があります。

ここで少し算数です。TimeManager クラスには、Picker で設定した最大時間を格納するプロパティ maxValue と 残り時間を格納するプロパティ duration が用意してあります。この2つの値を使って、カウントダウン開始時に最大値 1 カウントダウン終了時に最小値 0 になる計算式は duration / maxValue です。

引数 to の値のデータ型は CGFloat する必要がありますので、最終的に引数 to に入れる値は以下になります。

CGFloat(self.timeManager.duration / self.timeManager.maxValue)

よって、コードは以下のようになります。

ProgressBar.swift
struct ProgressBarView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        ZStack {
            //背景用の円
            Circle()
                //(モディファイア省略)

            //プログレスバー用の円
            Circle()
                .trim(from: 0, to: CGFloat(self.timeManager.duration / self.timeManager.maxValue))
                .stroke(Color(.cyan), style: StrokeStyle(lineWidth: 20, lineCap: .round, lineJoin: .round))
                .scaledToFit()
                //輪郭線の開始位置を12時の方向にする
                .rotationEffect(Angle(degrees: -90))
                .padding(25)
        }
    }
}

5. MainView にプログレスバーを配置する

MainView の body{} 内一番外側の ZStack{} の一番上に ProgressBarView のインスタンスを追加します。ZStack のコード上一番上ということは、UI コンポーネントのレイヤー階層では一番後ろになります。イメージとしては、残り時間の表示や時間設定のPickerのほうが、画面上、手前にある状態です。

また、先に 設定画面である SettingView の項目に、プログレスバーの表示/非表示のトグルスイッチを用意していますので、その設定と連動している TimeManager クラスの isProgressBaron プロパティが true の場合だけプログレスバーを表示するように if 文で記述します。

MainView.swift
struct MainView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        ZStack {
            if timeManager.isProgressBarOn {
                ProgressBarView()
            }

            if timeManager.timerStatus == .stopped {
                PickerView()
            } else {
                TimerView()
            }

            VStack {
                Spacer()
                ZStack {
                    ButtonsView()
                        .padding(.bottom)

                    SettingButtonView()
                        .padding(.bottom)
                        .sheet(isPresented: $timeManager.isSetting) {
                            SettingView()
                                .environmentObject(self.timeManager)
                        }
                }
            }
        }
        .onReceive(timeManager.timer) { _ in
            //(onReceive の中の記述省略)
        }
    }
}

6. プログレスバーの動きを滑らかにする

プログレスバーは実装できましたが、Xcode Canvas や Simulator で MainView の実際の動きを確認してみると、タイマーの設定時間が短いほど、プログレスバーが1秒毎に短くなる(カクカクした)動きがよくわかると思います。プログレスバーとして失敗ではありませんが、視覚的に滑らかなほうが洗練された印象を受けますので、ここを少し拘って修正していきます。

このカクカクした動きの原因は2つありますので、それぞれ修正していきます。

まず1つめは、TimeManager クラスの timer プロパティです。このプロパティには、Timer クラスの publish メソッドが格納されていますが、その引数 every の値が 1 になっています。これは1秒毎に発動するという意味になります。この値を 0.05 くらいに変更しておきます。検証の結果 0.01 を切るあたりから、現実の時間経過とタイマーアプリの残り時間の更新に誤差が出てくるので、0.05 くらいが限界かと思います。

TimeManager クラスで以下のように更新します。

TimeManager.swift
class TimeManager: ObservableObject {
    //(他のプロパティ省略)

    //1秒ごとに発動するTimerクラスのpublishメソッド
    var timer = Timer.publish(every: 0.05, on: .main, in: .common).autoconnect()

    //(メソッド省略)

2つ目は、MainView の onReceive モディファイア内の記述です。このモディファイアが先に修正した TimeManager クラスの timer プロパティをトリガーにして、クロージャ {} 内のコードを実行しています。トリガーを 0.05 秒更新にしたので、onReceive モディファイアのクロージャ {} 内に記述された TimeManager クラスの duration プロパティ(残り時間)の更新もまた 0.05 秒ずつマイナスされる必要があります。

MainView で以下のように更新します。

MainView.swift
struct MainView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        ZStack {
            //(省略)
        }
        //指定した時間(1秒)ごとに発動するtimerをトリガーにしてクロージャ内のコードを実行
        .onReceive(timeManager.timer) { _ in
            //タイマーステータスが.running以外の場合何も実行しない
            guard self.timeManager.timerStatus == .running else { return }
            //残り時間が0より大きい場合
            if self.timeManager.duration > 0 {
                //残り時間から -0.05 する
                self.timeManager.duration -= 0.05 //ここを更新!
                //残り時間が0以下の場合
            } else {
                //タイマーステータスを.stoppedに変更する
                self.timeManager.timerStatus = .stopped
                //アラーム音を鳴らす
                AudioServicesPlayAlertSoundWithCompletion(self.timeManager.soundID, nil)
                //バイブレーションを作動させる
                AudioServicesPlayAlertSoundWithCompletion(SystemSoundID(kSystemSoundID_Vibrate)) {}
            }
        }
    }
}

これで、例えばタイマーを5秒に設定してもプログレスバーが比較的滑らかな動きをしてくれます。
次回は、少しおまけ的要素ですが、プログレスバーの色をより美しく表示していきます。

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

【Swift】NSPredicateで全角半角の区別ができないことについて

NSPredicateでの全半角の区別

下記のコードは携帯番号が半角英数字で入力されているかを判定する処理です。
一見正常に動きそうですが、これでは全角の「090-0000-0000」と半角の「090-0000-0000」のどちらを判定してもtrueが返ってきて全半角の区別がつきません。

func checkPhoneNum(string: String) -> Bool {
  let format = "[0-9]{3}-[0-9]{4}-[0-9]{4}"
  let predicate = NSPredicate(format:"SELF MATCHES %@", format)
  return predicate.evaluate(with: string)
}

NSRegularExpressionでの全半角の区別

上記のようにNSPredicateでは全半角の区別がつかないため、NSRegularExpressionで下記のように判定します。これで全角の場合はfalse、半角の場合はtrueで返ってきます。

func checkPhoneNum(string: String) -> Bool {
  let format = "[0-9]{3}-[0-9]{4}-[0-9]{4}"
  let regexp = try! NSRegularExpression.init(pattern: format, options: [])
  let nsString = string as NSString
  let matchRet = regexp.firstMatch(in: string, options: [], range: NSRange.init(location: 0, length: nsString.length))
  return matchRet != nil
}

参考

http://aryzae.hatenablog.com/entry/2017/12/13/004159

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

[Swift]Realmファイル内容の確認方法

1. この記事を書いた理由

iOSアプリを作るぞ!
→ Realm Swiftを使ってみよう!
 → 実装してみたけどRealmファイルがちゃんと作られているかわからないよ〜!
  → 確認しよう! (今ここ)

2. 実行環境

  • MacOS Catalina : 10.15.7(19H2)
  • Xcode : Version 12.0.1 (12A7300)
  • Realm Studio : バージョン5.0.1 (5.0.1.6)

(2020年10月23日辺り)

3. 手順

目次
3.1. 前提条件
3.2. Realm Studioのインストール
3.3. Realmファイルの居場所を確認
3.4. Realmファイルの確認

3.1.前提条件

  • シミュレータ上で実行。実機では試していません。(実機手順は3.3章の公式ドキュメントに記載あり)
  • Realm Swiftを用いて、Realmファイルの生成までは済んでいる状態。以下は生成の例。
example.swift
            let realm =  try! Realm()
            let data = "save data"

            try! realm.write {
                realm.add(data)
            }

3.2. Realm Studioのインストール

3.2.1. インストール

公式HPよりRealm Studio-5.x.x.dmgをダウンロード。実行してインストール。

3.2.2. 起動

Launchpadより Realm Studioを起動する。

※「発行元が信頼されていないため、起動できません」という旨の警告が表示されて起動できない場合は、以下手順を実施。

「システム環境設定」
 →「セキュリティとプライバシー」
  →「一般」
   →「ダウンロードしたアプリケーションの実行許可」
     にRealm Studioが表示されているため、許可する。

3.2.3. 起動後

サインアップを求められるので、メールアドレスを登録。
スクリーンショット 2020-10-23 20.46.22.png

3.3.Realmファイルの居場所を確認

公式情報によると、以下フォルダに生成されるとのこと。

/Users/<username>/Library/Developer/CoreSimulator/Devices//data/Containers/Data/Application/<application-uuid>/Documents/default.realm

引用元:公式ドキュメントHow to find my realm file?

ファイルの場所を確認する方法は、シミュレータ使用時にコマンド実行することで可能。
以下に説明していく。

3.3.1. Realmファイル生成後のタイミングにbreakポイントを張ってアプリを実行

スクリーンショット 2020-10-23 7.59.56.png

停止すると下部に(lldb)というコンソールが出る。

スクリーンショット 2020-10-23 8.03.42.png

3.3.2. lldbコンソールにてコマンド実行
po Realm.Configuration.defaultConfiguration.fileURL

スクリーンショット 2020-10-23 8.12.17.png

するとファイルパスが出力されます。

3.4. Realmファイルの確認

ターミナルを開き、以下のコマンドを実行する。
(前手順で調べたRalmファイルパスをopenコマンドで開く)

open /Users/<username>/Library/Developer/CoreSimulator/Devices/<simulator-uuid>/data/Containers/Data/Application/<application-uuid>/Documents/default.realm

すると Realm Studio画面が起動して・・・
ファイル内容が見れた!!! こうなってるんですね〜。
スクリーンショット 2020-10-23 6.33.01.png

4. おわりに

お読みいただきありがとうございました。
iOSアプリ開発(勉強)を初めてまだまだ新米で分からないことだらけですが、少しずつ出来ていくのが楽し〜です。
本記事もどこかの同じようなアプリ開発者の参考になれば幸いです。

またQiita記事も初投稿でしたが、こちらもこれからもっと発信していきたいな〜と思います。
もし良かったらLGTM/フォロー等のアクションいただけるとスゴい嬉しいです!?

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

【Swift】どこまで勉強したら実務に飛び込むべきなのか

Twitterを見ていると、
「そんなレベルまで勉強しているの!?」
と思うことがよくあります。

逆に、
「(そのレベルじゃ厳しいだろうな...)」
ということもあります。

初学者にとってどこまでを実務へのラインとするべきなのかがはっきりしない為、
当然の現象だと思います。
あくまで一個人の体験に基づいて、どのラインまで身に付けたら
実務へ飛び込むべきなのかを書いていきます。

実務へのライン

最低限身に付けたい
Swiftエンジニアとして、独学でどこまで勉強するべきかをリストアップしてみました。
・基本的な実装
・基本的な実装のカスタマイズができる
・APIからデータを取得し、利用できる
・Cocoapodsでライブラリをインストールし、カスタマイズができる
・sourcetree・githubが使える
・Firestoreが使える
・ある程度のUI/UX感覚
・ある程度のエラーを自分で解決することができる

大体ここまで出来ていれば、すぐにでも実務に飛び込むべきです。
独学は実務に敵わないからです。
ライブラリも、いくつも勉強する必要はありません。
チャットライブラリや図形ライブラリなどが使えれば十分かと思います。

実務に入るラインま出来たらあとは実務で学ぶのが良いでしょう。
プロジェクトに携わり、実際に動くコードで書かれている物を見本とできるので、
吸収効率も違いますし、何よりわかりやすいです。(百聞は一見にしかず)

最低限身に付けたいスキルを詳しく

・基本的な実装
こちらはXcodeのコアライブラリにあるようなTableViewやCollectionViewなどの
基本的な実装です。それが出来たら次はカスタマイズをしていきます。
・基本的な実装のカスタマイズができる
TableViewやCollectionViewのレイアウトなどを自分好みにカスタマイズできるように
いじくりまわします。カスタムセルなどもここで抑えたいですね。
・APIからデータを取得し、利用できる
非同期処理の勉強をします。DispatchQueueやクロージャを使い、
APIを利用できるようにします。
・Cocoapodsでライブラリをインストールし、カスタマイズができる
ライブラリを使用しないプロジェクトはほとんどありません。
pod installでのライブラリインストールに慣れておきたいですね。
あとは外部ライブラリをカスタマイズできるようにします。
(外部ライブラリの使用は面接用のポートフォリオなどで役に立ちます。)
・sourcetree・githubが使える
定番のプロジェクト管理ツールです。
最低限使えるようにしたいところです。
・Firestoreが使える
ポートフォリオとしてある程度役に立ちます。
・ある程度のUI/UX感覚
どんなに複雑なロジックよりも、第三者の目をまず惹くことができるのがUIです。
ここに拘れると評価が高くなりやすいです。
・ある程度のエラーを自分で解決することができる
言うまでもなく、これはエンジニアとしてとても大切なポイントですね。

おわりに

いかがでしたでしょうか。
いつまでもインプットせず、まず実務に入ることを目的として勉強するのが
良いかと思います。

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

【未経験の方必見】アプリエンジニアが狙い目な理由

こんにちは。
この記事では、
「なぜアプリエンジニアが狙い目なのか」
「なぜアプリエンジニアが少ないのか」
にフォーカスを置いて書いていきます。

なぜアプリエンジニアが狙い目なのか

理由は簡単です。
母数が少ないからです。
この理由から、需要に対しての供給が足りていません。
経験者の採用を諦め、未経験者の教育に力を入れている会社もあるようです。

実際、弊社では全ポジションを募集していますが、
アプリエンジニアの面談回数はサーバーサイドやフロントに比べてかなり少ないです。

サーバーサイドやフロントに対してアプリエンジニアは
大体5分の1〜10分の1ほどしか応募がきません。
アプリエンジニアの中でもAndroidエンジニアは特に、iOSエンジニアの10分の1ほどしか
来ないため、Androidエンジニアの希少性はかなり強いと言えます。

なぜアプリエンジニアが少ないのか

Twitterを通していろいろな意見を見たり、実際に勉強会で交流を通して意見を聞いてきた中で、
アプリエンジニアが少ない理由がいくつか見えてきました。

苦手意識

初学者の人はまず、「どの言語をやるか」「どのポジションをやるか」
について選択が迫られます。
この時、「フロントの方がすぐに結果が見えてわかりやすい」
という意見がよく見られました。

また、「プログラミングの勉強を始めるならまずHTML・CSS
という暗黙の何かがあるように思えました。
(「HTML・CSSはプログラミング言語じゃない」という気持ちはわかります)
そこから派生してJavascript・jQuery・Vue.jsなどフロントエンジニアの道を歩む人が
多いのではないでしょうか。

アプリエンジニアを勧める環境が少ない

初学者の言語選択に対して、「PHP×Javascript」「Rails」
を勧める環境が多いように感じられました。
特に「PHP×Javascript」は、「初学者が最短で仕事につくのに最適だ」「一番簡単だ」
と勧める人をよく見かけました。

Railsが多い理由はスクールの影響が大きいかと思います。
確かにわかりやすいですし、レール(rail)にのっとった書き方ができるため初学者に優しいかもしれませんが、
逆に言えば経験者が爆速開発するためのフレームワークで少数精鋭でやるものだと言う声もあり、
初学者に本当に優しいのかと聞かれると
はっきりとYesと言えない言語です。

これらから分かるように、初学者にとってウケがいい言語は「手頃(そう)な言語」で、
またウケを狙った(?)スクールによってRails学習者が増え、
バランスが偏ってしまっているのではないかと推測しました。

iOSエンジニアはMacBookが必要

正確に言えば、MacBookがなくてもOSをぶち込めれば良いのですが、
いきなりの初学者がそんなことを思いつきませんし、できません。
勉強を始めるためにMacBookを買うと言うフィルターがあるために、
アプリエンジニアの頭数が少ないのだと推測しました。

Androidユーザーが少ない

アプリエンジニアの道に進んだとしても、iOSかAndroidかの分岐があります。
ここでは、多くの人がiOSエンジニアを選択することでしょう。
なぜなら日本では、世界でも稀にiPhoneユーザーが多いためです。
Android端末を使ったことがないのにAndroidの勉強をする人はかなり稀です。

結局のところアプリエンジニアは少ない

これらの理由から、エンジニア市場には偏りが見られます。
iOSエンジニアをやるにしても高価なMacbookが必要で、
Androidエンジニアへの道も国内OSシェアからその道に進む人があまりいません。

サーバー・インフラ>>アプリエンジニア(iOS >> Android)

のような構図が見られるため、ただでさえアプリエンジニアは狙い目であるのに
Androidエンジニアとなれば更に母数が減るのです。

これからプログラミングの勉強を始めようと考えている方がいましたら、
ぜひアプリエンジニアを検討してみてはいかがでしょうか。

誤解してはいけない

誤解してはいけないのが、「供給が足りないのだから簡単なんだ!」と言うことです。
あくまでも一定レベルのスキルは必要であり、それなりの勉強時間が必要になります。
はっきり言ってエンジニアになるには大変です。
数ヶ月でなれる人は天才だと思います。

最後まで記事を読んでいただきありがとうございました。

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

駆け出しSwiftエンジニアにオススメしたい脱初心者 良著三選

『iOSアプリ設計パターン入門』

Swiftのアーキテクチャが学べます。
未経験の面接でここまで語れるとデキるやつ感が出る。Fatにならない設計は大事。
https://peaks.cc/books/iOS_architecture

RxSwift研究読本シリーズ

めちゃくちゃわかりやすい、Rxの本。
RxSwiftが必要なら必読な本。
https://booth.pm/ja/items/1076262

プリンシプル オブ プログラミング3年目までに身につけたい一生役立つ101の原理原則

これぐらいは押さえておきましょう的なやつ。鉄板のリーダブルコードもオススメ。
https://www.amazon.co.jp/dp/4798046140/ref=cm_sw_r_tw_dp_x_N7SHFbSTTS6GP

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

[Swift5]少数点第二位や第三位の四捨五入・切り捨て・切り上げを行う方法

必要とする関数

四捨五入・切り捨て・切り上げを行う場合は以下の関数を使います。

round() → 四捨五入
fool() → 切り捨て
ceil() → 切り上げ

使用例

ViewController.swift
let num = 7.5

let numRound = round(num)   
// 8(四捨五入)

let numFloor = floor(num)   
// 7切り捨て)

let numCeil  = ceil(num)    
// 8(切り上げ)

少数点第二位や第三位の四捨五入・切り捨て・切り上げ

ViewController.swift
let num = 3.1415

let numRound = round(num*10)/10   
// 3.1(小数第2位で四捨五入)

let numFloor = floor(num*100)/100   
// 3.14(小数第3位で切り捨て)

let numCeil =ceil(num*1000)/1000   
// 3.142(小数第4位で切り上げ)

以上です。参考にしてください!

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

Mintでバージョン管理する SwiftLint/SwiftFormat/SwiftGen - 環境構築の手順

LinterやFormatterもバージョン管理したい!

Xcodeで開発するときに便利なツールたちですが、HomeBrewで入れることも多いですよね?
開発者の端末ごとのバージョン違いで微妙に挙動が違って、PRのたびに無駄な差分が発生してしまします。
そこで、重い腰を上げて、Mintでバージョン管理をすることにしました。
この記事では、導入手順とハマりポイントを紹介したいと思います。

Mintのインストール

これに関しては、HomeBrewでもいいかも……(え

brew install mint

ちゃんとやるなら、gitから直接!

$ git clone https://github.com/yonaskolb/Mint.git
$ cd Mint
$ swift run mint install yonaskolb/mint

新規プロジェクトの作成

今回は Storyboard/UIKit App Delegate を選択しました。

.gitignoreを追加してコミット

こちら.gitignoreをコピーさせていただいて、いったんコミットします。

➜  ToolsSample git init
➜  ToolsSample git:(master) ✗ git add .gitignore 
➜  ToolsSample git:(master) ✗ git add .
➜  ToolsSample git:(master) ✗ git commit -m '[chore] Initial commit with .gitignore'

SwiftLint/SwiftFormatの導入

Mintfileを作成して、mint bootstrap

最新版をインストールするため、いったんバージョン指定なしで作成します。
導入は後ですが、SwiftGenもここで一緒にインストールしてしまいます。

Mintfile
realm/SwiftLint
nicklockwood/SwiftFormat
SwiftGen/SwiftGen

mint bootstrapでリポジトリをクローンして、ビルドします。

➜  ToolsSample git:(master) ✗ mint bootstrap
? Finding latest version of SwiftLint
? Cloning SwiftLint 0.40.3
? Resolving package
? Building package
? Installed SwiftLint 0.40.3
? Finding latest version of SwiftFormat
? Cloning SwiftFormat 0.47.0
? Resolving package
? Building package
? Installed SwiftFormat 0.47.0
? Finding latest version of SwiftGen
? Cloning SwiftGen 6.4.0
? Resolving package
? Building package
? Copying resources for SwiftGen: templates, Resources/Stencil-Info.plist, Resources/StencilSwiftKit-Info.plist, Resources/SwiftGen-Info.plist, Resources/SwiftGenKit-Info.plist ...
? Installed SwiftGen 6.4.0
? Installed 3/3 packages

インストールされた最新バージョンを確認して、Mintfileをアップデート。プロジェクトで使用するツールのバージョンを固定できます。

Mintfile
realm/SwiftLint@0.40.3
nicklockwood/SwiftFormat@0.47.0
SwiftGen/SwiftGen@6.4.0

Run Scriptの追加

Linter/Formatterを手動で毎度走らせるのはつらいので、ビルドのタイミングで実行するようにします。

TARGETSを選択して、Build Phasesのタブの左上のプラスボタンからNew Run Script Phaseを選択して、Run Scriptを追加します。

Run Scriptにコマンドを追加

SwiftLintとSwiftFormatは一部のフォーマット機能がかぶるので、SwiftLintのautocorrectをかけたあとに、SwiftFormatでフォーマットします。

RunScript
mint run swiftlint autocorrect
mint run swiftlint lint
mint run swiftformat --swiftversion 5.3 .

SwiftLintやSwiftFormatのREADMEでは、whichコマンドでツールの存在をチェックしてから実行する方法が記載してありますが、存在チェックを省いておくとツールが利用できない場合はXcodeがビルドエラーで教えてくれます。

動作確認

ここまでできたら、ビルド(Command+b)してみて、SwiftLintが警告を出してくれることを確認しましょう。
ViewControllerのソースコードのインデントを適当に変更してみると、SwiftFormatがインデントを整えてくれることも確認できます。

SwiftGenの導入

ここではLocalizable.stringsからファイル生成をやってみます。

ローカライズの設定

ProjectのInfoタブから、Localizationsのプラスボタンをクリックして、日本語を選択します。

適当な設定を選んでFinish

Localizable.stringsの作成

新しくResoucesグループを作って

New file...からStrings Fileを選択して、Localizable.stringsを作成します。

ファイルのインスペクタからLocalize...を押して、Japaneseを選択してLocalize!

インスペクタに戻って、上で選択しなかった方の言語(ここではEnglish)にもチェックを入れる。

それぞれの言語に対応する、lprojディレクトリとLocalizable.stringsが生成されます。

➜  ToolsSample git:(master)ls ToolsSample/Resources/*.lproj  
ToolsSample/Resources/en.lproj:
Localizable.strings

ToolsSample/Resources/ja.lproj:
Localizable.strings

ここでは、ひとつだけ文字列を登録してみましょう。

Locaizable.strings
// Resources/ja.lproj/Localizable.strings

"greetings.hello" = "こんにちは"

// Resources/en.lproj/Localizable.strings

"greetings.hello" = "Hello"


SwiftGenのconfigファイルの生成

フレームワークごとに文字列のファイルを生成する場合を考えて、configファイルはフレームワーク内に作成することにします。

➜  ToolsSample git:(master) ✗ mint run swiftgen config init --config ToolsSample/Resources/swiftgen.yml
Example configuration file created: ToolsSample/Resources/swiftgen.yml

SwiftGenの生成用のディレクトリの作成

SwiftGenが生成するファイルを配置するディレクトリを作成します。

swiftgen.ymlを編集

今回のディレクトリ構成に合わせて編集します。configファイルのパスを起点に記述するようです。

ディレクトリ構成
➜  ToolsSample git:(master) ✗ tree ToolsSample/Resources 
ToolsSample/Resources
├── en.lproj
│   └── Localizable.strings
├── ja.lproj
│   └── Localizable.strings
└── swiftgen.yml
swiftgen.yml
input_dir: ./
output_dir: ../Generated/

strings:
  inputs:
    - en.lproj
  filter:
    - .+\.strings$
  outputs:
    - templateName: structured-swift5
      output: Strings+Generated.swift

config initで生成したswiftgen.ymlに記載されているコメントにある通り、stringsのinputsにはローカライズのディレクトリをひとつだけを記載します。

config lintで記述をチェック

➜  ToolsSample git:(master) ✗ mint run swiftgen config lint --config ToolsSample/Resources/swiftgen.yml
Linting ToolsSample/Resources/swiftgen.yml
> Common parent directory used for all input paths:  ./
> Common parent directory used for all output paths: ../Generated/
swiftgen: error: output_dir: Output directory ../Generated/ does not exist.
> 1 entry for command strings:
 $ swiftgen strings --templateName structured-swift5 --output ../Generated/Strings+Generated.swift en.lproj

output pathsでエラーが出ていますが、どうやらoutput pathsだけはコマンドを実行したパスからの相対パスでチェックされるようです(バグ?)。実際に生成する際は問題なく動くので、今回は無視します。

SwiftGenによるファイルの生成

➜  ToolsSample git:(master) ✗ mint run swiftgen config run --config ToolsSample/Resources/swiftgen.yml
File written: ../Generated/Strings+Generated.swift

初回だけ、生成されたファイルをXcodeに追加します。

Strings+Generated.swift
internal enum L10n {
  internal enum Greetings {
    /// Hello
    internal static let hello = L10n.tr("Localizable", "greetings.hello")
  }
}

extension L10n {
  private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String {
    let format = BundleToken.bundle.localizedString(forKey: key, value: nil, table: table)
    return String(format: format, locale: Locale.current, arguments: args)
  }
}

private final class BundleToken {
  static let bundle: Bundle = {
    #if SWIFT_PACKAGE
    return Bundle.module
    #else
    return Bundle(for: BundleToken.self)
    #endif
  }()
}

Run Scriptに生成コマンドを追加

Localizable.stringsを編集するたびにコマンドラインから実行するのは面倒なので、ビルドのタイミングで実行するようにします。

RunScript
mint run swiftgen config run --config ToolsSample/Resources/swiftgen.yml # 追加!
mint run swiftlint autocorrect
mint run swiftlint lint
mint run swiftformat --swiftversion 5.3 .

.swiftformatを追加

ここでハマりポイントがあります。
このままの設定では、SwiftFormatがSwiftGenが生成したBundleTokenクラスをenumに変更してしまいます。

そこで、.swiftformatファイルを作成して、Generatedディレクトリをフォーマット対象から除外しておきます。

.swiftformat
# file options
--exclude ToolsSample/Generated

ちなみに、隠しファイルをXcodeプロジェクトに追加したい場合は、追加画面でCommand+Shift+.を入力すると、隠しファイルが表示されるようになります。

動作確認

おわりに

おつかれさまでした。
参考になれば幸いです!

参考

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

[swift5] いろんな作り方でAPIクライアントを作ってみる

はじめに

本記事は色んな作り方でAPIクライアントを作ってみる記事です。(タイトルまま)
モチベーションとしては非同期フレームワークのありなしやRxとCombineの違いが通信部分でどう出てくるのか書いてみて試したかった感じです。
なので以下の3パターンでミニマムなAPIクライアントを作ります。

  1. URLSession + Codable
  2. URLSession + RxSwift + Codable
  3. URLSession + Combine + Codable

実行環境

  • Xcode 12
  • Swift 5.3

リクエストは共通で同じものを使います。githubAPIのuser情報取得のエンドポイントです。

struct GetUserRequest: BaseRequest {

    typealias Response = UserResponse

    var path: String { "/users" + "/" + username}

    var method: HttpMethod { .get }

    let username: String

    struct Request: Encodable {}
}

struct UserResponse: Decodable {
    var login: String
    var id: Int
}

①URLSession + Codable

クライアント本体

protocol BaseRequest {
    associatedtype Request: Encodable
    associatedtype Response: Decodable

    var baseUrl: String { get }

    var path: String { get }

    var url: URL? { get }

    var method: HttpMethod { get }

    var headerFields: [String: String] { get }

    var encoder: JSONEncoder { get }

    var decoder: JSONDecoder { get }

    func request(_ parameter: Request?, completionHandler: ((Result<Response, APIError>) -> Void)?)
}

extension BaseRequest {

    var baseUrl: String { "https://api.github.com" }

    var url: URL? { URL(string: baseUrl + path) }

    var headerFields: [String: String] { [String: String]() }

    var defaultHeaderFields: [String: String] { ["content-type": "application/json"] }

    var encoder: JSONEncoder {
        let encoder = JSONEncoder()
        encoder.keyEncodingStrategy = .convertToSnakeCase
        return encoder
    }

    var decoder: JSONDecoder {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return decoder
    }

    func request(_ parameter: Request? = nil, completionHandler: ((Result<Response, APIError>) -> Void)? = nil) {
        do {
            let data = parameter == nil ? nil : try encoder.encode(parameter)
            request(data, completionHandler: completionHandler)
        } catch {
            completionHandler?(.failure(.request))
        }
    }

    func request(_ data: Data?, completionHandler: ((Result<Response, APIError>) -> Void)? = nil) {
        do {
            guard let url = url, var urlRequest = try method.urlRequest(url: url, data: data) else { return }
            urlRequest.allHTTPHeaderFields = defaultHeaderFields.merging(headerFields) { $1 }
            urlRequest.timeoutInterval = 8

            var dataTask: URLSessionTask!
            dataTask = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
                if let error = error {
                    completionHandler?(.failure(.responseError(nsError)))
                    return
                }

                guard let data = data, let response = response as? HTTPURLResponse else {
                    completionHandler?(.failure(.emptyResponse))
                    return
                }

                guard 200..<300 ~= response.statusCode else {
                    completionHandler?(.failure(.http(status: response.statusCode)))
                    return
                }

                do {
                    let entity = try self.decoder.decode(Response.self, from: data)
                    completionHandler?(.success(entity))
                } catch {
                    completionHandler?(.failure(.decode))
                }
            }
            dataTask.resume()
        } catch {
            completionHandler?(.failure(.request))
        }
    }
}

enum APIError: Error {
    case request
    case response(error: Error? = nil)
    case emptyResponse
    case decode(Error)
    case http(status: Int, data: Data)
}

enum HttpMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
    case patch = "PATCH"

    func urlRequest(url: URL, data: Data?) throws -> URLRequest? {
        var request = URLRequest(url: url)
        switch self {
        case .get:
            guard let data = data else {
                request.httpMethod = rawValue
                return request
            }

            guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true),
                let dictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return nil }

            components.queryItems = dictionary.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
            guard let getUrl = components.url else { return nil }
            var request = URLRequest(url: getUrl)
            request.httpMethod = rawValue
            return request

        case .post, .put, .delete, .patch:
            request.httpMethod = rawValue
            request.httpBody = data
            return request
        }
    }
}

利用方法

GetUserRequest(username: "hoge").request(.init()) { [weak self] result in
    switch result {
    case .success(let response):
        guard let self = self else { return }
        self.response = response

    case .failure(let error):
        self.showAlert(message: error.localizedDescription)
    }
}

感想

クロージャを渡しておいて通信後に実行します。よくみる書き方です。
Combineなどの非同期フレームワークを導入しないプロジェクトの場合、どこもこんな感じになりそうです。

②URLSession + RxSwift + Codable

クライアント本体

extension BaseRequest {

...

    private func request(_ data: Data?) -> Single<Response> {
        return Single.create(subscribe: { observer -> Disposable in
            do {
                guard let url = self.url, var urlRequest = try self.method.urlRequest(url: url, data: data) else {
                    return Disposables.create()
                }
                urlRequest.allHTTPHeaderFields = self.defaultHeaderFields.merging(self.headerFields) { $1 }
                urlRequest.timeoutInterval = 8

                let dataTask = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
                    if let error = error {
                        observer(.error(error))
                    }

                    guard let data = data, let response = response as? HTTPURLResponse else {
                        observer(.error(APIError.response))
                        return
                    }

                    guard 200..<300 ~= response.statusCode else {
                        observer(.error(APIError.http(status: response.statusCode, data: data)))
                        return
                    }

                    do {
                        let entity = try self.decoder.decode(Response.self, from: data)
                        observer(.success(entity))
                    } catch {
                        observer(.error(APIError.decode(error)))
                    }
                }
                dataTask.resume()
                return Disposables.create()

            } catch {
                return Disposables.create()
            }
        })
    }
}

利用方法

private let resultRelay: BehaviorRelay<Result<LoginResponse, Error>?> = BehaviorRelay(value: nil)
private let disposeBag: DisposeBag = DisposeBag()

GetUserRequest(username: "hoge").request(.init())
    .subscribe(onSuccess: { response in
        self.resultRelay.accept(.success(response))

    }, onError: { error in
        self.resultSubject.accept(.failure(error))

    })
    .disposed(by: disposeBag)

var result: Observable<Result<LoginResponse, Error>?> {
    return resultRelay.asObservable()
}

感想

通信後の結果のobservableを受け取ったらBehaviorRelayに値を渡します。
ほぼcompletionのクロージャをリクエストに渡す実装と変わらずに書けますね。
あとはrelayをobserveしておけばViewModelやViewへのコールバックが楽に実装できそうです。
RxなしだとdidSetで結果を監視したりクロージャにクロージャが連続したりで実装する箇所が、宣言的になっています。

③URLSession + Combine + Codable

クライアント本体

extension BaseRequest {

...

    private func request(_ data: Data?) -> Future<Response, APIError> {
        return .init { promise in
            do {
                guard let url = self.url,
                      var urlRequest = try self.method.urlRequest(url: url, data: data) else {
                    return
                }
                urlRequest.allHTTPHeaderFields = self.defaultHeaderFields.merging(self.headerFields) { $1 }
                urlRequest.timeoutInterval = 8

                let dataTask = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
                    if let error = error {
                        promise(.failure(.response(error: error)))
                    }

                    guard let data = data, let response = response as? HTTPURLResponse else {
                        promise(.failure(.response()))
                        return
                    }

                    guard 200..<300 ~= response.statusCode else {
                        promise(.failure(.http(status: response.statusCode, data: data)))
                        return
                    }

                    do {
                        let entity = try self.decoder.decode(Response.self, from: data)
                        promise(.success(entity))
                    } catch {
                        promise(.failure(APIError.decode(error)))
                    }
                }
                dataTask.resume()
            } catch {
                promise(.failure(error))
            }
        }
    }
}

利用方法

private var binding = Set<AnyCancellable>()
@Published private(set) var response: Response?

let exp = expectation(description: "Success")

GetUserRequest(username: "hoge").request(.init())
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            // do something when loading finished.
        case .failure(let error):
            // do something when the error occured.
        }
    }, receiveValue: { [weak self] response in

        guard let self = self else { return }

        self.response = response

    }).store(in: &binding)

waitForExpectations(timeout: 20)

感想

Futureは一度だけ値を発行するPublisherで、RxSwiftのSingleみたいな感じです。
参考:Combine で RxSwift の Single を置きかえる - Qiita

Combineはジェネリクスでエラーのタイプを定義できるのでエラーをキャストする必要がありません。
また、Futureは.initのtrailing closureでResult型を引数にとれます。

public typealias Promise = (Result<Output, Failure>) -> Void

public init(_ attemptToFulfill: @escaping (@escaping Future<Output, Failure>.Promise) -> Void)

なので、promise(.success(entity))でシンプルに完了時のハンドリングできるのが良いですね。

Futureから発行された値はsinkで受け取り、recieveValuereceiveCompletionでハンドリングします。
個人的には値のハンドリングと結果のハンドリング分かれているのが気持ち悪いです。
(Neverを使っている=失敗はない場合はreceiveValueのみのsinkが使用できます。)

Result型のクロージャをcompletionに渡しあげる実装のように、successのcaseのassociatedValueでresponseも受け取れたらいいんですが、combineを使う場合は上記のsinkを利用する必要があるので厳しそうです。

まとめ

どの書き方を採用してもAPIクライアント自体は似たような作りになりそうでした。
なので途中から非同期フレームワークを導入することになっても通信部分で恐ることはなさそうです。(むしろモデルに近いAPIクライアントから積極的に変えていくべきかも)

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

iOSアプリ開発:タイマーアプリ(7.アラーム音選択の実装)

記事内容

タイマーアプリを作成するためのポイントを複数記事に分けて掲載しています。
この記事では、アラーム音の選択画面の実装について掲載します。

開発環境

  • OS: macOS 10.15.7 (Catalina)
  • エディタ: Xcode 12.1
  • 言語: Swift
  • 主な使用ライブラリ: SwiftUI

手順概要

  1. アラーム音の構造体に必要な要素(プロパティ)を検討する
  2. Data にアラーム音の Struct を作成する
  3. アラーム音のリストを作成する
  4. アラーム音を試聴できるようにする
  5. 選択したアラーム音をTimeManagerクラスのプロパティに反映させる
  6. 設定画面、アラーム音リスト間の画面遷移を実装する

1. アラーム音の構造体に必要な要素(プロパティ)を検討する

現時点では、アラーム音がデフォルトで指定されたものになっており、変更できません。これをいくつかのアラーム音を用意し、設定画面から変更可能にしていきます。

アラーム音のオブジェクトとして必要な要素は何か考えると、以下の2つがあれば成立します。

  • サウンドID:AudioToolbox ライブラリのメソッドで音を鳴らすために必要
  • サウンド名:設定画面やリスト画面に表示するために必要

2. Data にアラーム音の Struct を作成する

Data.swift ファイルに Sound という名前の構造体を作成し、そのプロパティとして、手順1で考えた2つの要素を含めます。

このとき、サウンドIDプロパティのデータ型は SystemSoundID でなければなりません。このデータ型は AudioToolbox ライブラリに含まれますので、必ずインポートします。

サウンドIDによって、この構造体から作成されたインスタンスが一意である必要があるので、 Identifiable プロトコル を継承します。

Data.swift
import SwiftUI
import AudioToolbox //追加import

//(他のenum省略)

struct Sound: Identifiable {
    let id: SystemSoundID
    let soundName: String
}

3. アラーム音のリストを作成する

SoundListView という名前の新しい Swift ファイルを SwiftUI テンプレートから作成します。システムサウンドを利用するため、AudioToolbox ライブラリをインポートしておきます。

ほかの View と同様に、TimeManager クラスのプロパティを常に参照したいので、そのインスタンスを作成します。@EnvironmentObject プロパティラッパーを var の前につけます。

SoundListView
import SwiftUI
import AudioToolbox

struct SoundListView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        Text("Hello, World!")
    }
}

次に、アラーム音のデータを用意します。

手順1で作成した Sound 構造体から id プロパティと soundName プロパティを指定しながら、インスタンスを作成し、それを Sounds と名付けた Array に格納します。具体的なサウンド ID は以下のリソースを参考にします。soundName もリソースにあるファイル名を参考に、わかりやすいものにします。
https://github.com/TUNER88/iOSSystemSoundsLibrary

SoundListView
struct SoundListView: View {
    @EnvironmentObject var timeManager: TimeManager

    let sounds: [Sound] = [
        Sound(id: 1151, soundName: "Beat"),
        Sound(id: 1304, soundName: "Alert"),
        Sound(id: 1309, soundName: "Glass"),
        Sound(id: 1310, soundName: "Horn"),
        Sound(id: 1313, soundName: "Bell"),
        Sound(id: 1314, soundName: "Electronic"),
        Sound(id: 1320, soundName: "Anticipate"),
        Sound(id: 1327, soundName: "Minuet"),
        Sound(id: 1328, soundName: "News Flash"),
        Sound(id: 1330, soundName: "Sherwood Forest"),
        Sound(id: 1333, soundName: "Telegraph"),
        Sound(id: 1334, soundName: "Tiptoes"),
        Sound(id: 1335, soundName: "Typewriterst"),
        Sound(id: 1336, soundName: "Update")
    ]

    var body: some View {
        Text("Hello, World!")
    }
}

body{} 内の一番外枠には List{} を用意します。この List は SettingView で利用した Form に似ていて、表形式でたくさんのアイテムを罫線で区切って表示するときに利用します。

List{} 内に、Array 内のアイテムを順番に並べるときは、ForEach が便利です。ForEach(sounds) とすることで、sounds 内のアイテムに対して、順番にループ処理してくれます。処理内容は{}内に記述します。

まずは {} 内にテキストでサウンド名を表示するように記述します。

SoundListView
struct SoundListView: View {
    @EnvironmentObject var timeManager: TimeManager

    let sounds: [Sound] = [
        //(各サウンドオブジェクト省略)
    ]

    var body: some View {
        List {
            ForEach(sounds) {sound in
                //soundNameの値でリストに表示する
                Text(("\(sound.soundName)"))
            }
        }
    }
}

4. アラーム音を試聴できるようにする

実際のアプリの操作として、アラームを選択するときにどんな音なのかを確認したいものです。

試聴用のアイコンをリストの各行の左端に表示させて、それをタップするとサウンドが再生される、という仕様にしていきます。

ForEach{} 内に HStack{} を追加して、各行ごとにコンポーネントが横並びになるようにします。

HStack{} の一番最初に、アイコンとして Image コンポーネントを追加します。SF Symbols から "speaker.2.fill" を選びました。そして、.onTapGesture {} モディファイアを追加し、クロージャ {} 内に AudioToolbox ライブラリのメソッド AudioServicesPlayAlertSoundWithCompletion() を記述します。引数を sound.id とすることで、タップした行の id を soundID として拾って再生されます。第二引数は nil で構いません。

SoundListView
struct SoundListView: View {
    //(プロパティ省略)

    var body: some View {
        List {
            ForEach(sounds) {sound in
                //リストの行に関する記述
                HStack {
                    //試聴用のアイコン
                    Image(systemName: "speaker.2.fill")
                        .onTapGesture {
                            AudioServicesPlayAlertSoundWithCompletion(sound.id, nil)
                    }           
                    //soundNameの値でリストに表示する
                    Text(("\(sound.soundName)"))
                }
            }
        }
        .navigationBarTitle("Alarm Sound Setting", displayMode: .automatic)
    }
}

5. 選択したアラーム音をTimeManagerクラスのプロパティに反映させる

行をタップすると、そのサウンドが選択されるようにします。

具体的には、HStack{} がリスト内の行に相当するので、そのモディファイアとして .onTapGesture{} を追加します。.onTapGesture{} のクロージャ {} 内には、TimeManagerクラスのすでに用意されている soundID プロパティと soundName プロパティに選択した行のサウンドIDとサウンド名がそれぞれ代入されるようにします。

ただし、これだけだと、サウンドリスト上のサウンド名の表示をタップしないと選択できません。行の空白部分をタップしても無反応です。細かいですが、ここも修正していきます。

HStackのモディファイアとして、.onTapGesture{} より前に、.contentShape(Rectangle()) を追加します。これにより、HStackが四角いオブジェクトとみなされる、つまり行の全体がひとつのオブジェクトになり、空白箇所をタップしても、アラーム音を選択できるようになります。

SoundListView
struct SoundListView: View {
    //(プロパティ省略)

    var body: some View {
        List {
            ForEach(sounds) {sound in
                //リストの行に関する記述
                HStack {
                    //試聴用のアイコン
                    Image(systemName: "speaker.2.fill")
                        .onTapGesture {
                            AudioServicesPlayAlertSoundWithCompletion(sound.id, nil)
                    }           
                    //soundNameの値でリストに表示する
                    Text(("\(sound.soundName)"))
                }
                //HStackを四角いオブジェクトとみなす
                .contentShape(Rectangle())
                //行をタップでサウンド選択(IDと名前をTimeManagerへ反映)
                .onTapGesture {
                    self.timeManager.soundID = sound.id
                    self.timeManager.soundName = sound.soundName
                }
            }
        }
    }
}

さらに、現在選択されているサウンド名の右側にはチェックマークが表示されるように、 if 構文で記載します。チェックマークは行の右端に寄せたいので、サウンド名とチェックマークの間に Spacer() を入れます。

SoundListView
struct SoundListView: View {
    //(プロパティ省略)

    var body: some View {
        List {
            ForEach(sounds) {sound in
                //リストの行に関する記述
                HStack {
                    //試聴用のアイコン
                    Image(systemName: "speaker.2.fill")
                        .onTapGesture {
                            AudioServicesPlayAlertSoundWithCompletion(sound.id, nil)
                    }           
                    //soundNameの値でリストに表示する
                    Text(("\(sound.soundName)"))

                    Spacer()

                    //現在選択されているサウンドの場合はチェックマークを表示
                    if self.timeManager.soundID == sound.id {
                        Image(systemName: "checkmark")
                    }
                }
                //HStackを四角いオブジェクトとみなす
                .contentShape(Rectangle())
                //行をタップでサウンド選択(IDと名前をTimeManagerへ反映)
                .onTapGesture {
                    self.timeManager.soundID = sound.id
                    self.timeManager.soundName = sound.soundName
                }
            }
        }
    }
}

6. 設定画面、アラーム音リスト間の画面遷移を実装する

メインの設定画面 SettingView にアラーム音選択の項目を追加し、サウンドリスト画面 SoundListView へ遷移するようにします。

SettingView の body{} 内の一番外側は NavigationView{} で囲った状態になっています。これにより、その中の Form{} 内に NavigationLink{} を追加することで画面をさらに次の階層に遷移させることができます。

設定項目名を Text() にて "Sound Selection" とし、その行の右端に、現在選択中のアラーム音の名前が表示されるように Spacer() を間に挟んで、 Text() にて TimeManagerクラスのプロパティ soundName を指定します。

Form のモディファイアとして、以下の2つを追加し、NavigationBar(画面上部)の表示と、NavigationView全体の表示スタイルを指定します。

.navigationBarTitle()
.navigationViewStyle()

SettingView.swift
struct SettingView: View {
    //(プロパティ省略)

    var body: some View {
        NavigationView {
            Form {
                Section(header: Text("Alarm:")) {
                    Toggle(isOn: $timeManager.isAlarmOn) {
                        Text("Alarm Sound")
                    }
                    Toggle(isOn: $timeManager.isVibrationOn) {
                        Text("Vibration")
                    }
                    //サウンド選択画面へ画面遷移
                    NavigationLink(destination: SoundListView()) {
                        HStack {
                            //設定項目名
                            Text("Sound Selection")
                            Spacer()
                            //現在選択中のアラーム音
                            Text("\(timeManager.soundName)")
                        }
                    }
                }
                Section(header: Text("Animation:")) {
                    //(Animationセクションの内容省略)
                }
                Section(header: Text("Save:")) {
                    //(Saveセクションの内容省略)
                }
            }
            .navigationBarTitle("Setting", displayMode: .automatic)
            .navigationViewStyle(DefaultNavigationViewStyle())
        }
    }
}

そして、SoundListView の List{} に .navigationBarTitle モディファイアを追加し、画面のタイトル "Alarm Sound Setting" をつけます。

SoundListView
struct SoundListView: View {
    //(プロパティ省略)

    var body: some View {
        List {
            ForEach(sounds) {sound in
                //リストの行に関する記述
                HStack {
                    //試聴用のアイコン
                    Image(systemName: "speaker.2.fill")
                        .onTapGesture {
                            AudioServicesPlayAlertSoundWithCompletion(sound.id, nil)
                    }           
                    //soundNameの値でリストに表示する
                    Text(("\(sound.soundName)"))

                    Spacer()

                    //現在選択されているサウンドの場合はチェックマークを表示
                    if self.timeManager.soundID == sound.id {
                        Image(systemName: "checkmark")
                    }
                }
                //行をタップでサウンド選択(IDと名前をTimeManagerへ反映)
                .onTapGesture {
                    self.timeManager.soundID = sound.id
                    self.timeManager.soundName = sound.soundName
                }
            }
        }
        .navigationBarTitle("Alarm Sound Setting", displayMode: .automatic)
    }
}

これで、アラーム音選択を含めた設定画面の実装ができあがりました。次回は視覚的に楽しいプログレスバーの実装について掲載します。

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