20200328のiOSに関する記事は6件です。

Swiftでiosアプリ開発【個人記録】

概要

「たった2日でマスターできるiPhoneアプリ開発集中講座 Xcode 11 Swift 5対応」
に沿って、Swiftでのiosアプリ開発を学習してみることにしました。その記録用です。

じゃんけんアプリ

楽器アプリ

パーツの配置

楽器アプリには、シンバル、ギター、Play、Stopというパーツがある。
それぞれ、のパーツは押すことによってプログラムが動作することが要求されるため、パーツはそれぞれButtonである。

使う画像をAssets.xcassetsに格納する

これで、プログラムやAttributesInspectorから画像を呼び出せるようになる。

パーツを配置する

AttributesInspectorから+ボタンで、配置したいパーツを選択し、それをドラッグ&ドロップで配置していく。
スクリーンショット 2020-03-28 18.25.59.png

配置されたパーツのレイアウトを変更する

AttributesInspectorで全て行うことができる。
Buttonには、取り込んだImageを反映することもでき、それもここで行う。
スクリーンショット 2020-03-28 18.28.21.png

配置されたパーツのAutoLayoutを設定する

AutoLayoutというのは、様々なデバイスで適切にパーツが表示されるための便利な設定みたいなもの。
中央から垂直方向、水平方向にどれだけ距離があるかを設定することで、様々なデバイスで適切な見た目になるようにすることができる。
スクリーンショット 2020-03-28 18.36.36.png
スクリーンショット 2020-03-28 18.37.44.png

水平方向マイナス:左側
水平方向プラス:右側
垂直方向マイナス:上側
垂直方向プラス:下側

に設定した値だけ、中央からパーツがずれていく。
例えば、ギターは、中央からみて、右上に配置したいので、
Horizontally in Container : 80
Vertically in Container : -80
となる。

音源を準備する

a.音源ファイルをMyMusic配下に格納する

スクリーンショット 2020-03-28 19.26.50.png

b.AVFoundationを読み込む

import UIKit
//追加
import AVFoundation

class ViewController: UIViewController {

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

AVfoundationとは、音や画像を扱いやすくしてくれるフレームワークである。

c.音源ファイルのパスと、AVfoundationのインスタンスを生成する

import UIKit
import AVFoundation

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
//定数cymbalPathに、音源ファイルのパスを格納。Bundleクラスはファイルや画像を管理しているクラス。
    let cymbalPath = Bundle.main.bundleURL.appendingPathComponent("cymbal.mp3")
//変数cymbalPlayerにAVVudioAVVudioplayerクラスのインスタンスを格納し、そのクラスのメソッドを使えるようにしておく。
    var cymbalPlayer = AVAudioPlayer()

パーツとプログラムを関連づける

a.パーツをCtrlキーを押したまま、ドラッグ&ドロップ

b.音を再生するプログラムを作成

import UIKit
import AVFoundation

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
    let cymbalPath = Bundle.main.bundleURL.appendingPathComponent("cymbal.mp3")
    var cymbalPlayer = AVAudioPlayer()
//追加
    @IBAction func cymbal(_ sender: Any) {
//AVAudioplayerに音源ファイルを指定
    do{
        cymbalPlayer = try AVAudioPlayer(contentsOf:cymbalPath, fileTypeHint:nil)
//音の再生
            cymbalPlayer.play()
        }catch{
            print("シンバルで、エラーが発生しました。")
        }
    }
    let guitarPath = Bundle.main.bundleURL.appendingPathComponent("guitar.mp3")
       var guitarPlayer = AVAudioPlayer()

    @IBAction func guitar(_ sender: Any) {
        do{
               guitarPlayer = try AVAudioPlayer(contentsOf:guitarPath, fileTypeHint:nil)
                   guitarPlayer.play()
               }catch{
                   print("ギターで、エラーが発生しました。")
               }
    }
}

例外処理
Swiftでは、例外が発生しうるクラスを利用する時は、例外処理を明示して書かないとエラーになってしまう。

do {
    try メソッド呼び出し
}catch {
    エラー処理
}

のように書く。
AudioPlayerとかBundleとかクラスがあって、それを継承して、そこのメソッドを使うためにインスタンスを作成して音を鳴らしているというのは理解できるけれど、具体的に各クラスがどのようになっているのかまではわかっていない。
とりあえず今は先に進んで、あとで戻ってきたときに、そこは調べたいと思う。

完成形

import UIKit
import AVFoundation

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
//シンバルの音源のPathと、AVAudioPlayerのインスタンスを作成
    let cymbalPath = Bundle.main.bundleURL.appendingPathComponent("cymbal.mp3")
    var cymbalPlayer = AVAudioPlayer()
//シンバル(ボタン)と関連づけ
    @IBAction func cymbal(_ sender: Any) {
//AVAudioplayerに音源ファイルを指定
    do{
        cymbalPlayer = try AVAudioPlayer(contentsOf:cymbalPath, fileTypeHint:nil)
//音の再生
            cymbalPlayer.play()
        }catch{
            print("シンバルで、エラーが発生しました。")
        }
    }
//ギターの音源のPathと、AVAudioPlayerのインスタンスを作成
    let guitarPath = Bundle.main.bundleURL.appendingPathComponent("guitar.mp3")
    var guitarPlayer = AVAudioPlayer()
//ギター(ボタン)と関連づけ
    @IBAction func guitar(_ sender: Any) {
//AVAudioplayerに音源ファイルを指定
        do{
               guitarPlayer = try AVAudioPlayer(contentsOf:guitarPath, fileTypeHint:nil)
//音の再生
                   guitarPlayer.play()
               }catch{
                   print("ギターで、エラーが発生しました。")
               }
    }
//BGMの音源のPathと、AVAudioPlayerのインスタンスを作成
    let backmusicPath = Bundle.main.bundleURL.appendingPathComponent("backmusic.mp3")
    var backmusicPlayer = AVAudioPlayer()
//Play(ボタン)との関連づけ
    @IBAction func play(_ sender: Any) {
//AVAudioplayerに音源ファイルを指定

        do{
            backmusicPlayer = try AVAudioPlayer(contentsOf: backmusicPath, fileTypeHint: nil)
//ボタン押下後の再生回数を定義
            backmusicPlayer.numberOfLoops = -1
//音の再生

            backmusicPlayer.play()
        }catch{
            print("エラーが発生しました。")
        }
    }
//Stop(ボタン)と関連づけ
    @IBAction func stop(_ sender: Any) {
//BGMの停止
        backmusicPlayer.stop()
    }
}

image.png

リファクタリング

共通部分(音楽再生やパスをAVAudioPlayerに渡したりするところ)が多いので、そこをメソッドにしてしまう。
あと、定数や変数の定義は、最初にしてしまった方がみやすい。

import UIKit
import AVFoundation

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
//共通部分をメソッド化
    fileprivate func soundPlayer(player:inout AVAudioPlayer,path:URL,count: Int){
        do{
            player = try AVAudioPlayer(contentsOf: path, fileTypeHint: nil)
            player.numberOfLoops = count
            player.play()
        }catch{
            print("エラーが発生しました")
        }
    }
//シンバルの音源のPathと、AVAudioPlayerのインスタンスを作成
    let cymbalPath = Bundle.main.bundleURL.appendingPathComponent("cymbal.mp3")
    var cymbalPlayer = AVAudioPlayer()
//ギターの音源のPathと、AVAudioPlayerのインスタンスを作成
    let guitarPath = Bundle.main.bundleURL.appendingPathComponent("guitar.mp3")
    var guitarPlayer = AVAudioPlayer()
//BGMの音源のPathと、AVAudioPlayerのインスタンスを作成
    let backmusicPath = Bundle.main.bundleURL.appendingPathComponent("backmusic.mp3")
    var backmusicPlayer = AVAudioPlayer()
//シンバル(ボタン)と関連づけ
    @IBAction func cymbal(_ sender: Any) {
    soundPlayer(player: &cymbalPlayer, path: cymbalPath, count: 0)
    }
//ギター(ボタン)と関連づけ
    @IBAction func guitar(_ sender: Any) {
    soundPlayer(player: &guitarPlayer, path: guitarPath, count: 0)
    }
//Play(ボタン)との関連づけ
    @IBAction func play(_ sender: Any) {
    soundPlayer(player: &backmusicPlayer, path: backmusicPath, count: -1)
    }
//Stop(ボタン)と関連づけ
    @IBAction func stop(_ sender: Any) {
//BGMの停止
        backmusicPlayer.stop()
    }
}

メソッド定義部分

fileprivate func soundPlayer(player:inout AVAudioPlayer,path:URL,count: Int)

fileprivate・・・アクセス修飾詞といって、このファイルの中でのみ呼び出せるメソッドになる。
player:inout AVAudioPlayer・・・AVAudioPlayerクラスの変数を、受け取って、最終的にこの変数を戻り値として返すよ、という意味。returnを明記する必要がなくなる。

呼び出し部分

@IBAction func play(_ sender: Any) {
    soundPlayer(player: &backmusicPlayer, path: backmusicPath, count: -1)
    }

soundPlayerにそれぞれ3つの引数をわたしている。
左からインスタンス、音源ファイルのパス、そして繰り返す回数である。(-1はループになる)

参照渡し
playerという引数は、参照渡しという方法でメソッド側に値が渡されている。
呼び出し元の変数がメソッドの影響を受けるとき、参照渡しが行われる。

参照渡しと値渡しの例
http://swift-salaryman.com/inout.php
より引用

import UIKit
class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        var testInt = 0;
        println(testInt);//結果ー>0
        sanshouWatashi_NO(testInt);
        println(testInt);//結果ー>0
        sanshouWatashi_YES(&testInt);
        println(testInt);//結果ー>1
    }

    //参照渡しではない通常(呼び出し元影響受けない)
    func sanshouWatashi_NO(var param1:Int){
        param1 += 1;
    }

    //参照渡し(呼び出し元影響受ける)
    func sanshouWatashi_YES(inout param1:Int){
        param1 += 1;
    }

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

cf.AVAudioPlayer

https://developer.apple.com/documentation/avfoundation/avaudioplayer
AVAudioPlayerクラスで使えるメソッド等が公開されている。

感想

超シンプルじゃんけんアプリと音楽再生アプリを作成してみて、なんとなく流れを掴めた。
まずはパーツを配置して、そのパーツの大きさやフォーマットを整える。
そしてそのパーツとプログラムをリンクさせる。
とりあえずまずは、この本を最後までやりたい。

地図アプリ

パーツ配置

1.検索窓
Text fieldを配置し、Attributesを赤枠のように変更することで、クリアボタンと、キーボードの「改行」が「検索」になるように設定する。
基本的にはパーツの細かい設定はAttributes Inspectorで設定することができる。
スクリーンショット 2020-03-31 11.20.57.png

2.地図
地図を表示するキット"Map Kit View"を配置する。
画面全体に表示させたいのでAutoLayoutはすべて0。そうすることで、パーツや画面からの余白が上下左右0になるので、画面全体に表示される。

関連づけ

image.png
OutletとAction
両方ともConnectionはOutletで設定する。
Actionはパーツを作動させたときになんらかのプログラムを動作させるとき、
Outletはパーツを作動させたときに、パーツ自体が持つ情報を表示したり、操作したりするとき
と使い分ける。
【Swift/iOS】UI部品の接続方法

入力された文字をデバックで表示させる(要復習)

import UIKit
import MapKit
class ViewController: UIViewController ,UITextFieldDelegate{

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        inputText.delegate = self
    }


    @IBOutlet weak var inputText: UITextField!

    @IBOutlet weak var dispMap: MKMapView!

//検索が押されたときに実行されるメソッド
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
//キーボードを閉じる
        textField.resignFirstResponder()

//もしserchKey(値はtextFieldより代入)があれば
        if let serchKey = textField.text{
            print(serchKey)
        }
        return true
    }
}

delegateとは
猿がもがきまくって理解したSwiftのデリゲート(Delegate)という仕組み
【Swift】hogehoge.delegate = self は何をしているのか。
別のクラスでの連絡手段がdelegateというらしい。とりあえず先に進みます。

入力された文字から緯度経度を検索する(要復習)

func textFieldShouldReturn(_ textField: UITextField) -> Bool {

        // キーボードを閉じる
        textField.resignFirstResponder()

        // 入力された文字を取り出す
        if let searchKey = textField.text {
            // 入力された文字をデバッグエリアに表示
            print(searchKey)

            // CLGeocoderインスタンスを生成
            let geocoder = CLGeocoder()


            geocoder.geocodeAddressString(searchKey , completionHandler: { (placemarks, error) in

                if let unwrapPlacemarks = placemarks {

                    if let firstPlacemark = unwrapPlacemarks.first {

                        if let location = firstPlacemark.location {


                            let targetCoordinate = location.coordinate

                            // 緯度経度をデバッグで表示
                            print(targetCoordinate)       

geocoder

 geocoder.geocodeAddressString(searchKey , completionHandler: { (placemarks, error) in

ここを理解するためにはまず、geocoderの正体と、クロージャーという概念について知っておく必要がある。
geocoder・・・地図情報を取得するメソッドや情報が格納されるプロパティが用意されているクラス。これを使用するためにはインスタンスが必要なので、生成している。
インスタンスで読んでいるメソッドは、

open func geocodeAddressString(_ addressString: String, completionHandler: @escaping CLGeocodeCompletionHandler)

それぞれのパラメータは、知りたい位置情報のロケーション、結果とともに実行されるブロック(?)

addressString
A string describing the location you want to look up. For example, you could specify the string “1 Infinite Loop, Cupertino, CA” to locate Apple headquarters.
completionHandler
The handler block to execute with the results. The geocoder executes this handler regardless of whether the request was successful or unsuccessful. For more information on the format of this block, see CLGeocodeCompletionHandler.

-CLGeocodeCompletionHandler.-
placemark
Contains an array of CLPlacemark objects. For most geocoding requests, this array should contain only one entry. However, forward-geocoding requests may return multiple placemark objects in situations where the specified address could not be resolved to a single location.
If the request was canceled or there was an error in obtaining the placemark information, this parameter is nil.
error
Contains nil or an error object indicating why the placemark data was not returned. For a list of possible error codes, see CLError.Code.

クロージャー・・・中身の処理が確定したときに、代入されるデータのこと・・・?

let hello = {
   print("こんにちは")
}

{}内の処理が定数helloに代入される。

また、引数を取ったり、戻り値も設定することができる。

let hello = { (person:String)-> String in
  return print("hello by \(person)" )
}

let greet = hello("John")
print(greet)
//hello by Johnが出力される

定義

{ (引数名:引数の型) -> 戻り値の型 in
    処理
 }

これを踏まえて改めて、該当のコードをみてみる。

func textFieldShouldReturn(_ textField: UITextField) -> Bool {

     // キーボードを閉じる
        textField.resignFirstResponder()

        // 入力された文字を取り出す
        if let searchKey = textField.text {
            // 入力された文字をデバッグエリアに表示
            print(searchKey)

            // CLGeocoderインスタンスを生成
            let geocoder = CLGeocoder()


            geocoder.geocodeAddressString(searchKey , completionHandler: { (placemarks, error) in
                //placemarksがなんらかの値を持っているとき、定数に代入
                if let unwrapPlacemarks = placemarks {

                    if let firstPlacemark = unwrapPlacemarks.first {

                        if let location = firstPlacemark.location {


                            let targetCoordinate = location.coordinate

                            // 緯度経度をデバッグで表示
                            print(targetCoordinate)       

1.入力されたロケーションが入っているsearchKeyを、geocoderのメソッドに渡す。
2.二つ目の引数はクロージャーなので、{}内の処理の結果が格納されてメソッドに渡る?
が、{}内をみると、そこの処理が送られると言うよりは、メソッドが送ってきたplacemarksに入っている情報を元に処理を書いている気がする・・・

細かい点を一度置いておくと、下記のような流れになっている。

1.場所名が入力される
2.入力された場所を元にその情報を取得するメソッドが実行される
3.もし情報があれば、緯度経度がデバックで出力される。

疑問点

1.placemarksがなんの宣言もないのに使えるのはなぜか。
2.クロージャーの概要・使うメリット

参照

Swiftの completionHandlerについて説明します。
【Swift】クロージャ(基本編)
[iOS] MapKitを使って”ジオコーディング・逆ジオコーディング”をやってみる
geocodeAddressString(_:completionHandler:)

地図にピンを表示させる

// MKPointAnnotationインスタンスを生成
                            let pin = MKPointAnnotation()

// ピンの置く場所に緯度経度を設定
//targetCoordinateには、検索された場所の緯度経度が入っているのでそのまま渡す
                            pin.coordinate = targetCoordinate

// ピンのタイトルを設定(ピンのすぐ下に表示される)
                            pin.title = searchKey

// ピンを地図に置く 
                            self.dispMap.addAnnotation(pin)

// 緯度経度を中心にして半径500mの範囲を表示
                            self.dispMap.region = MKCoordinateRegion(center: targetCoordinate, latitudinalMeters: 500.0, longitudinalMeters: 500.0)

MKPointAnnotationのインスタンスpinでなく、selfなのは、addAnnotationというメソッドが、ViewControllerにインポートしたMapKitのメソッドだから。

地図表示を切り替えられるようにする

@IBAction func changeMapButton(_ sender: Any) {
        if dispMap.mapType == .standard {
                   dispMap.mapType = .satellite
               } else if dispMap.mapType == .satellite {
                   dispMap.mapType = .hybrid
               } else if dispMap.mapType == .hybrid {
                   dispMap.mapType = .satelliteFlyover
               } else if dispMap.mapType == .satelliteFlyover {
                   dispMap.mapType = .hybridFlyover
               } else if dispMap.mapType == .hybridFlyover {
                   dispMap.mapType = .mutedStandard
               } else {
                   dispMap.mapType = .standard
               }
           }
    }

既に切り替えボタンはパーツとして配置した。
それぞれ、mapTypeを判別して、違う種類のものになるように切り替えることができる。
種類の詳細は下記参照。
SwiftでMapタイプの切り替え方

感想

delegateやクロージャーといった概念や使い方は、この先何度も戻って学習しないとわからないなと感じた。
わからない箇所は逐一元のところまで辿って調べるようにしているが、Swiftやiosの下地がなさすぎて、調べると調べた先のことがわからないといったことになることが多い。
もちろん、都度調べることは重要であるとは思うものの、いつまでも詰まっていて先に進めないと、先に進んでからわかることがどんどんと先延ばしになってしまうので、この本についてはまずはios開発をなんとなく把握すること一つの目標基準にしたいと思う。
Railsなどでもそうだったけど、いろんなことを学習してから、また戻ってきたときにしっくりくることが多いし、いつまでもとどまることに固執すると俯瞰的にみたときに帰って、非効率になってしまうことが心配。

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

Swiftでiosアプリ開発-1【個人記録】

概要

「たった2日でマスターできるiPhoneアプリ開発集中講座 Xcode 11 Swift 5対応」
に沿って、Swiftでのiosアプリ開発を学習してみることにしました。その記録用です。

じゃんけんアプリ

楽器アプリ

パーツの配置

楽器アプリには、シンバル、ギター、Play、Stopというパーツがある。
それぞれ、のパーツは押すことによってプログラムが動作することが要求されるため、パーツはそれぞれButtonである。

1.使う画像をAssets.xcassetsに格納する

これで、プログラムやAttributesInspectorから画像を呼び出せるようになる。

2.パーツを配置する

AttributesInspectorから+ボタンで、配置したいパーツを選択し、それをドラッグ&ドロップで配置していく。
スクリーンショット 2020-03-28 18.25.59.png

3.配置されたパーツのレイアウトを変更する

AttributesInspectorで全て行うことができる。
Buttonには、取り込んだImageを反映することもでき、それもここで行う。
スクリーンショット 2020-03-28 18.28.21.png

4.配置されたパーツのAutoLayoutを設定する

AutoLayoutというのは、様々なデバイスで適切にパーツが表示されるための便利な設定みたいなもの。
中央から垂直方向、水平方向にどれだけ距離があるかを設定することで、様々なデバイスで適切な見た目になるようにすることができる。
スクリーンショット 2020-03-28 18.36.36.png
スクリーンショット 2020-03-28 18.37.44.png

水平方向マイナス:左側
水平方向プラス:右側
垂直方向マイナス:上側
垂直方向プラス:下側

に設定した値だけ、中央からパーツがずれていく。
例えば、ギターは、中央からみて、右上に配置したいので、
Horizontally in Container : 80
Vertically in Container : -80
となる。

5.音源を準備する

a.音源ファイルをMyMusic配下に格納する

スクリーンショット 2020-03-28 19.26.50.png

b.AVFoundationを読み込む

import UIKit
//追加
import AVFoundation

class ViewController: UIViewController {

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

AVfoundationとは、音や画像を扱いやすくしてくれるフレームワークである。

c.音源ファイルのパスと、AVfoundationのインスタンスを生成する

import UIKit
import AVFoundation

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
//定数cymbalPathに、音源ファイルのパスを格納。Bundleクラスはファイルや画像を管理しているクラス。
    let cymbalPath = Bundle.main.bundleURL.appendingPathComponent("cymbal.mp3")
//変数cymbalPlayerにAVVudioAVVudioplayerクラスのインスタンスを格納し、そのクラスのメソッドを使えるようにしておく。
    var cymbalPlayer = AVAudioPlayer()

6.パーツとプログラムを関連づける

a.パーツをCtrlキーを押したまま、ドラッグ&ドロップ

b.音を再生するプログラムを作成

import UIKit
import AVFoundation

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
    let cymbalPath = Bundle.main.bundleURL.appendingPathComponent("cymbal.mp3")
    var cymbalPlayer = AVAudioPlayer()
//追加
    @IBAction func cymbal(_ sender: Any) {
//AVAudioplayerに音源ファイルを指定
    do{
        cymbalPlayer = try AVAudioPlayer(contentsOf:cymbalPath, fileTypeHint:nil)
//音の再生
            cymbalPlayer.play()
        }catch{
            print("シンバルで、エラーが発生しました。")
        }
    }
    let guitarPath = Bundle.main.bundleURL.appendingPathComponent("guitar.mp3")
       var guitarPlayer = AVAudioPlayer()

    @IBAction func guitar(_ sender: Any) {
        do{
               guitarPlayer = try AVAudioPlayer(contentsOf:guitarPath, fileTypeHint:nil)
                   guitarPlayer.play()
               }catch{
                   print("ギターで、エラーが発生しました。")
               }
    }
}

例外処理
Swiftでは、例外が発生しうるクラスを利用する時は、例外処理を明示して書かないとエラーになってしまう。

do {
    try メソッド呼び出し
}catch {
    エラー処理
}

のように書く。
AudioPlayerとかBundleとかクラスがあって、それを継承して、そこのメソッドを使うためにインスタンスを作成して音を鳴らしているというのは理解できるけれど、具体的に各クラスがどのようになっているのかまではわかっていない。
とりあえず今は先に進んで、あとで戻ってきたときに、そこは調べたいと思う。

7.完成形

import UIKit
import AVFoundation

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
//シンバルの音源のPathと、AVAudioPlayerのインスタンスを作成
    let cymbalPath = Bundle.main.bundleURL.appendingPathComponent("cymbal.mp3")
    var cymbalPlayer = AVAudioPlayer()
//シンバル(ボタン)と関連づけ
    @IBAction func cymbal(_ sender: Any) {
//AVAudioplayerに音源ファイルを指定
    do{
        cymbalPlayer = try AVAudioPlayer(contentsOf:cymbalPath, fileTypeHint:nil)
//音の再生
            cymbalPlayer.play()
        }catch{
            print("シンバルで、エラーが発生しました。")
        }
    }
//ギターの音源のPathと、AVAudioPlayerのインスタンスを作成
    let guitarPath = Bundle.main.bundleURL.appendingPathComponent("guitar.mp3")
    var guitarPlayer = AVAudioPlayer()
//ギター(ボタン)と関連づけ
    @IBAction func guitar(_ sender: Any) {
//AVAudioplayerに音源ファイルを指定
        do{
               guitarPlayer = try AVAudioPlayer(contentsOf:guitarPath, fileTypeHint:nil)
//音の再生
                   guitarPlayer.play()
               }catch{
                   print("ギターで、エラーが発生しました。")
               }
    }
//BGMの音源のPathと、AVAudioPlayerのインスタンスを作成
    let backmusicPath = Bundle.main.bundleURL.appendingPathComponent("backmusic.mp3")
    var backmusicPlayer = AVAudioPlayer()
//Play(ボタン)との関連づけ
    @IBAction func play(_ sender: Any) {
//AVAudioplayerに音源ファイルを指定

        do{
            backmusicPlayer = try AVAudioPlayer(contentsOf: backmusicPath, fileTypeHint: nil)
//ボタン押下後の再生回数を定義
            backmusicPlayer.numberOfLoops = -1
//音の再生

            backmusicPlayer.play()
        }catch{
            print("エラーが発生しました。")
        }
    }
//Stop(ボタン)と関連づけ
    @IBAction func stop(_ sender: Any) {
//BGMの停止
        backmusicPlayer.stop()
    }
}

image.png

8.リファクタリング

共通部分(音楽再生やパスをAVAudioPlayerに渡したりするところ)が多いので、そこをメソッドにしてしまう。
あと、定数や変数の定義は、最初にしてしまった方がみやすい。

import UIKit
import AVFoundation

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
//共通部分をメソッド化
    fileprivate func soundPlayer(player:inout AVAudioPlayer,path:URL,count: Int){
        do{
            player = try AVAudioPlayer(contentsOf: path, fileTypeHint: nil)
            player.numberOfLoops = count
            player.play()
        }catch{
            print("エラーが発生しました")
        }
    }
//シンバルの音源のPathと、AVAudioPlayerのインスタンスを作成
    let cymbalPath = Bundle.main.bundleURL.appendingPathComponent("cymbal.mp3")
    var cymbalPlayer = AVAudioPlayer()
//ギターの音源のPathと、AVAudioPlayerのインスタンスを作成
    let guitarPath = Bundle.main.bundleURL.appendingPathComponent("guitar.mp3")
    var guitarPlayer = AVAudioPlayer()
//BGMの音源のPathと、AVAudioPlayerのインスタンスを作成
    let backmusicPath = Bundle.main.bundleURL.appendingPathComponent("backmusic.mp3")
    var backmusicPlayer = AVAudioPlayer()
//シンバル(ボタン)と関連づけ
    @IBAction func cymbal(_ sender: Any) {
    soundPlayer(player: &cymbalPlayer, path: cymbalPath, count: 0)
    }
//ギター(ボタン)と関連づけ
    @IBAction func guitar(_ sender: Any) {
    soundPlayer(player: &guitarPlayer, path: guitarPath, count: 0)
    }
//Play(ボタン)との関連づけ
    @IBAction func play(_ sender: Any) {
    soundPlayer(player: &backmusicPlayer, path: backmusicPath, count: -1)
    }
//Stop(ボタン)と関連づけ
    @IBAction func stop(_ sender: Any) {
//BGMの停止
        backmusicPlayer.stop()
    }
}

メソッド定義部分

fileprivate func soundPlayer(player:inout AVAudioPlayer,path:URL,count: Int)

fileprivate・・・アクセス修飾詞といって、このファイルの中でのみ呼び出せるメソッドになる。
player:inout AVAudioPlayer・・・AVAudioPlayerクラスの変数を、受け取って、最終的にこの変数を戻り値として返すよ、という意味。returnを明記する必要がなくなる。

呼び出し部分

@IBAction func play(_ sender: Any) {
    soundPlayer(player: &backmusicPlayer, path: backmusicPath, count: -1)
    }

soundPlayerにそれぞれ3つの引数をわたしている。
左からインスタンス、音源ファイルのパス、そして繰り返す回数である。(-1はループになる)

参照渡し
playerという引数は、参照渡しという方法でメソッド側に値が渡されている。
呼び出し元の変数がメソッドの影響を受けるとき、参照渡しが行われる。

参照渡しと値渡しの例
http://swift-salaryman.com/inout.php
より引用

import UIKit
class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        var testInt = 0;
        println(testInt);//結果ー>0
        sanshouWatashi_NO(testInt);
        println(testInt);//結果ー>0
        sanshouWatashi_YES(&testInt);
        println(testInt);//結果ー>1
    }

    //参照渡しではない通常(呼び出し元影響受けない)
    func sanshouWatashi_NO(var param1:Int){
        param1 += 1;
    }

    //参照渡し(呼び出し元影響受ける)
    func sanshouWatashi_YES(inout param1:Int){
        param1 += 1;
    }

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

cf.AVAudioPlayer

https://developer.apple.com/documentation/avfoundation/avaudioplayer
AVAudioPlayerクラスで使えるメソッド等が公開されている。

感想

超シンプルじゃんけんアプリと音楽再生アプリを作成してみて、なんとなく流れを掴めた。
まずはパーツを配置して、そのパーツの大きさやフォーマットを整える。
そしてそのパーツとプログラムをリンクさせる。
とりあえずまずは、この本を最後までやりたい。

地図アプリ

パーツ配置

1.検索窓
Text fieldを配置し、Attributesを赤枠のように変更することで、クリアボタンと、キーボードの「改行」が「検索」になるように設定する。
基本的にはパーツの細かい設定はAttributes Inspectorで設定することができる。
スクリーンショット 2020-03-31 11.20.57.png

2.地図
地図を表示するキット"Map Kit View"を配置する。
画面全体に表示させたいのでAutoLayoutはすべて0。そうすることで、パーツや画面からの余白が上下左右0になるので、画面全体に表示される。

関連づけ

image.png
OutletとAction
両方ともConnectionはOutletで設定する。
Actionはパーツを作動させたときになんらかのプログラムを動作させるとき、
Outletはパーツを作動させたときに、パーツ自体が持つ情報を表示したり、操作したりするとき
と使い分ける。
【Swift/iOS】UI部品の接続方法

入力された文字をデバックで表示させる(要復習)

import UIKit
import MapKit
class ViewController: UIViewController ,UITextFieldDelegate{

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        inputText.delegate = self
    }


    @IBOutlet weak var inputText: UITextField!

    @IBOutlet weak var dispMap: MKMapView!

//検索が押されたときに実行されるメソッド
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
//キーボードを閉じる
        textField.resignFirstResponder()

//もしserchKey(値はtextFieldより代入)があれば
        if let serchKey = textField.text{
            print(serchKey)
        }
        return true
    }
}

delegateとは
猿がもがきまくって理解したSwiftのデリゲート(Delegate)という仕組み
【Swift】hogehoge.delegate = self は何をしているのか。
別のクラスでの連絡手段がdelegateというらしい。とりあえず先に進みます。

入力された文字から緯度経度を検索する(要復習)

func textFieldShouldReturn(_ textField: UITextField) -> Bool {

                       // キーボードを閉じる
        textField.resignFirstResponder()

        // 入力された文字を取り出す
        if let searchKey = textField.text {
            // 入力された文字をデバッグエリアに表示
            print(searchKey)

            // CLGeocoderインスタンスを生成
            let geocoder = CLGeocoder()

            // *1
            geocoder.geocodeAddressString(searchKey , completionHandler: { (placemarks, error) in

                if let unwrapPlacemarks = placemarks {

                    if let firstPlacemark = unwrapPlacemarks.first {

                        if let location = firstPlacemark.location {


                            let targetCoordinate = location.coordinate

                            // 緯度経度をデバッグで表示
                            print(targetCoordinate)       

geocoder

 geocoder.geocodeAddressString(searchKey , completionHandler: { (placemarks, error) in

ここを理解するためにはまず、geocoderの正体と、クロージャーという概念について知っておく必要がある。
geocoder・・・地図情報を取得するメソッドや情報が格納されるプロパティが用意されているクラス。これを使用するためにはインスタンスが必要なので、生成している。
インスタンスで読んでいるメソッドは、

open func geocodeAddressString(_ addressString: String, completionHandler: @escaping CLGeocodeCompletionHandler)

それぞれのパラメータは、知りたい位置情報のロケーション、結果とともに実行されるブロック(?)

addressString
A string describing the location you want to look up. For example, you could specify the string “1 Infinite Loop, Cupertino, CA” to locate Apple headquarters.
completionHandler
The handler block to execute with the results. The geocoder executes this handler regardless of whether the request was successful or unsuccessful. For more information on the format of this block, see CLGeocodeCompletionHandler.

-CLGeocodeCompletionHandler.-
placemark
Contains an array of CLPlacemark objects. For most geocoding requests, this array should contain only one entry. However, forward-geocoding requests may return multiple placemark objects in situations where the specified address could not be resolved to a single location.
If the request was canceled or there was an error in obtaining the placemark information, this parameter is nil.
error
Contains nil or an error object indicating why the placemark data was not returned. For a list of possible error codes, see CLError.Code.

クロージャー・・・中身の処理が確定したときに、代入されるデータのこと・・・?

let hello = {
   print("こんにちは")
}

{}内の処理が定数helloに代入される。

また、引数を取ったり、戻り値も設定することができる。

let hello = { (person:String)-> String in
  return print("hello by \(person)" )
}

let greet = hello("John")
print(greet)
//hello by Johnが出力される

定義

{ (引数名:引数の型) -> 戻り値の型 in
    処理
 }

これを踏まえて改めて、該当のコードをみてみる。

func textFieldShouldReturn(_ textField: UITextField) -> Bool {

                       // キーボードを閉じる
        textField.resignFirstResponder()

        // 入力された文字を取り出す
        if let searchKey = textField.text {
            // 入力された文字をデバッグエリアに表示
            print(searchKey)

            // CLGeocoderインスタンスを生成
            let geocoder = CLGeocoder()

            // *1
            geocoder.geocodeAddressString(searchKey , completionHandler: { (placemarks, error) in
                //placemarksがなんらかの値を持っているとき、定数に代入
                if let unwrapPlacemarks = placemarks {

                    if let firstPlacemark = unwrapPlacemarks.first {

                        if let location = firstPlacemark.location {


                            let targetCoordinate = location.coordinate

                            // 緯度経度をデバッグで表示
                            print(targetCoordinate)       

1.入力されたロケーションが入っているsearchKeyを、geocoderのメソッドに渡す。
2.二つ目の引数はクロージャーなので、{}内の処理の結果が格納されてメソッドに渡る?
が、{}内をみると、そこの処理が送られると言うよりは、メソッドが送ってきたplacemarksに入っている情報を元に処理を書いている気がする・・・

細かい点を一度置いておくと、下記のような流れになっている。

1.場所名が入力される
2.入力された場所を元にその情報を取得するメソッドが実行される
3.もし情報があれば、緯度経度がデバックで出力される。

疑問点

1.placemarksがなんの宣言もないのに使えるのはなぜか。
2.クロージャーの概要・使うメリット

参照

Swiftの completionHandlerについて説明します。
【Swift】クロージャ(基本編)
[iOS] MapKitを使って”ジオコーディング・逆ジオコーディング”をやってみる
geocodeAddressString(_:completionHandler:)

地図にピンを表示させる

// MKPointAnnotationインスタンスを生成
                            let pin = MKPointAnnotation()

// ピンの置く場所に緯度経度を設定
//targetCoordinateには、検索された場所の緯度経度が入っているのでそのまま渡す
                            pin.coordinate = targetCoordinate

// ピンのタイトルを設定(ピンのすぐ下に表示される)
                            pin.title = searchKey

// ピンを地図に置く 
                            self.dispMap.addAnnotation(pin)

// 緯度経度を中心にして半径500mの範囲を表示(16)
                            self.dispMap.region = MKCoordinateRegion(center: targetCoordinate, latitudinalMeters: 500.0, longitudinalMeters: 500.0)

MKPointAnnotationのインスタンスpinでなく、selfなのは、addAnnotationというメソッドが、ViewControllerにインポートしたMapKitのメソッドだから。

地図表示を切り替えられるようにする

@IBAction func changeMapButton(_ sender: Any) {
        if dispMap.mapType == .standard {
                   dispMap.mapType = .satellite
               } else if dispMap.mapType == .satellite {
                   dispMap.mapType = .hybrid
               } else if dispMap.mapType == .hybrid {
                   dispMap.mapType = .satelliteFlyover
               } else if dispMap.mapType == .satelliteFlyover {
                   dispMap.mapType = .hybridFlyover
               } else if dispMap.mapType == .hybridFlyover {
                   dispMap.mapType = .mutedStandard
               } else {
                   dispMap.mapType = .standard
               }
           }
    }

既に切り替えボタンはパーツとして配置した。
それぞれ、mapTypeを判別して、違う種類のものになるように切り替えることができる。
種類の詳細は下記参照。
SwiftでMapタイプの切り替え方

感想

delegateやクロージャーといった概念や使い方は、この先何度も戻って学習しないとわからないなと感じた。
わからない箇所は逐一元のところまで辿って調べるようにしているが、Swiftやiosの下地がなさすぎて、調べると調べた先のことがわからないといったことになることが多い。
もちろん、都度調べることは重要であるとは思うものの、いつまでも詰まっていて先に進めないと、先に進んでからわかることがどんどんと先延ばしになってしまうので、この本についてはまずはios開発をなんとなく把握すること一つの目標基準にしたいと思う。
Railsなどでもそうだったけど、いろんなことを学習してから、また戻ってきたときにしっくりくることが多いし、いつまでもとどまることに固執すると俯瞰的にみたときに帰って、非効率になってしまうことが心配。

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

初心者によるRxSwiftのデモ

RxSwiftとは

RxSwiftの前身はMicrosoftが開発した.NET用のライブラリ「Reactive Extensions」です。このReactive Extensionsの概念が有効だったため、数々のプログラミング言語へと移植されました。その中でSwiftに移植されたのがRxSwiftです。

リアクティブプログラミングとは

リアクティブプログラミングとは「値の変化」と、それに対する「振る舞い」の関係を宣言的に記述するプログラミングの一種です。
例えばボタンを押すとラベルの文字が変化する、のようなユーザーインタラクティブなシステムや、通信処理などの非同期処理などで簡潔にかけるため特に有用です。数々の言語へ移植されている点がその有用性の証明とも言えます。

SwiftではiOS13専用で公式よりCombineというフレームワークがリリースされましたが、これはRxSwiftの概念を多く真似して作られているようです。

Combineを理解するための前提として、また簡潔なコードを書くためRxSwiftを始めてみようと思います。

参考書籍: 比較して学ぶRxSwift入門

カウンターデモアプリ

以下のように、ボタンを押すとラベルの数字が増減、または0にリセットされるデモアプリを作ってみようと思います。

截圖 2020-03-28 15.18.52.png

https://github.com/Satoru-PriChan/RxSwiftCounterApp

コールバックで実装

比較対象としてコールバックで実装してみます。

CounterViewModel.swift
import Foundation
class CounterViewModel {
    private(set) var count = 0

    func incrementCount(callback: (Int) -> ()) {
        count += 1
        callback(count)
    }

    func decrementCount(callback: (Int) -> ()) {
        count -= 1
        callback(count)
    }

    func resetCount(callback: (Int) -> ()) {
        count = 0
        callback(count)
    }
}
ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet private weak var countLabel: UILabel!
    private var viewModel: CounterViewModel!


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

        viewModel = CounterViewModel()
    }


    @IBAction func countUp(_ sender: UIButton) {
        viewModel.incrementCount(callback: { [weak self] count in
            self?.updateCountLabel(count)
        })
    }

    @IBAction func countDown(_ sender: UIButton) {
        viewModel.decrementCount(callback: { [weak self] count in
            self?.updateCountLabel(count)
        })
    }

    @IBAction func reset(_ sender: UIButton) {
        viewModel.resetCount(callback: { [weak self] count in
            self?.updateCountLabel(count)
        })
    }

    private func updateCountLabel(_ count: Int) {
        countLabel.text = String(count)
    }
}

ボタンに対応するメソッドが一つずつある(IBAction)ので、現段階ではシンプルですが、ボタンが増えた場合は見にくくなります。

また、CounterViewModelの側で、イベント処理の時にcallbackを一々呼ばなくてはなりません。

デリゲートパターンで実装

CounterPresenter.swift
protocol CounterDelegate: class {
    func updateCount(count: Int)
}

class CounterPresenter {
    private var count = 0 {
        didSet  {
            delegate?.updateCount(count: count)
        }
    }

    private var delegate: CounterDelegate?

    func attachView(_ delegate: CounterDelegate) {
        self.delegate = delegate
    }

    func detachView() {
        self.delegate = nil
    }

    func incrementCount() {
        count += 1
    }

    func decrementCount() {
        count -= 1
    }

    func resetCount() {
        count = 0
    }
}
ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet private weak var countLabel: UILabel!

    private let presenter = CounterPresenter()


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

        presenter.attachView(self)
    }


    @IBAction func countUp(_ sender: UIButton) {
        presenter.incrementCount()
    }

    @IBAction func countDown(_ sender: UIButton) {
        presenter.decrementCount()
    }

    @IBAction func reset(_ sender: UIButton) {
        presenter.resetCount()
    }
}

extension ViewController: CounterDelegate {
    func updateCount(count: Int) {
        countLabel.text = String(count)
    }
}

コールバックを呼び出す手間は無くなりました。

ボタン一つに対しIBAction一つが対応している点はコールバックと同じです。

RxSwiftで実装

CounterViewModel.swift
import Foundation
import RxSwift
import RxCocoa

struct CounterViewModelInput {
    let countUpButton: Observable<Void>
    let countDownButton: Observable<Void>
    let countResetButton: Observable<Void>
}

protocol CounterViewModelOutput {
    var counterText: Driver<String?> { get }
}

protocol CounterViewModelType {
    var outputs: CounterViewModelOutput? { get }
    func setup(input: CounterViewModelInput)
}

class CounterRxViewModel: CounterViewModelType {
    var outputs: CounterViewModelOutput?

    private let countRelay = BehaviorRelay<Int>(value: 0)
    private let initialCount = 0
    private let disposeBag = DisposeBag()

    init() {
        self.outputs = self
        resetCount()
    }

    func setup(input: CounterViewModelInput) {
        input.countUpButton
            .subscribe(onNext: { [weak self] in
            self?.incrementCount()
            })
            .disposed(by: disposeBag)

        input.countDownButton
            .subscribe(onNext: { [weak self] in
            self?.decrementCount()
            })
            .disposed(by: disposeBag)

        input.countResetButton
            .subscribe(onNext: { [weak self] in
            self?.resetCount()
            })
            .disposed(by: disposeBag)
    }

    private func incrementCount() {
        let count = countRelay.value + 1
        countRelay.accept(count)
    }

    private func decrementCount() {
        let count = countRelay.value - 1
        countRelay.accept(count)

    }

    private func resetCount() {
        countRelay.accept(initialCount)
    }
}

extension CounterRxViewModel: CounterViewModelOutput {
    var counterText: Driver<String?> {
        return countRelay
            .map { "Rx pattern: \($0)" }
            .asDriver(onErrorJustReturn: nil)
    }
}
ViewController.swift
import UIKit
import RxSwift

class ViewController: UIViewController {

    @IBOutlet private weak var countLabel: UILabel!
    @IBOutlet weak var countUpButton: UIButton!
    @IBOutlet weak var countDownButton: UIButton!
    @IBOutlet weak var resetButton: UIButton!

    private let disposeBag = DisposeBag()

    private var viewModel: CounterRxViewModel!


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

        setupViewModel()
    }

    func setupViewModel() {
        viewModel = CounterRxViewModel()

        let input = CounterViewModelInput(countUpButton: countUpButton.rx.tap.asObservable(),
                                          countDownButton: countDownButton.rx.tap.asObservable(),
                                          countResetButton: resetButton.rx.tap.asObservable())

        viewModel.setup(input: input)

        viewModel.outputs?.counterText
            .drive(countLabel.rx.text)
            .disposed(by: disposeBag)
    }
}

ViewControllerにおいて、ボタンと反応するメソッドが一対一では無くなり、見やすくなっています。

ViewModelにおいて、delegate?.updateCountのようにデータ更新の通知を行わなくても良くなっています。イベントが発生した場合、自動で通知が流れていきます。

比較的記述量が多い問題があります。極めて規模の小さいプロジェクトには向かないかもしれません。

WebViewアプリ

次はプログレスバー、アクティビティインジケータ(グルグル回る物)を備えたブラウザアプリを実装します。

截圖 2020-03-28 17.55.32.png
截圖 2020-03-28 17.55.49.png

https://github.com/Satoru-PriChan/RxSwiftWKWebViewDemo

通常のKVOパターンと、RxSwiftを使って実装し比較します。

KVOパターンで実装

KVOとはkey value observingの略で、プロパティの値の変化を監視する仕組みのことを言います(参照 https://qiita.com/ObuchiYuki/items/d00ce5f44725672184da

ViewController.swift
import UIKit
import WebKit

class ViewController: UIViewController {

    @IBOutlet weak var webView: WKWebView!
    @IBOutlet weak var progressView: UIProgressView!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!

    private var observers = [NSKeyValueObservation]()

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

        observers.append(webView.observe(\.isLoading,
                                         options: .new,
                                         changeHandler: { [weak self] (webView, change) in
            //observer webView's estimatedProgress

                                            guard change.newValue != nil else { return }

            if change.newValue ?? false {
                //when loading
                if self?.activityIndicator.isAnimating == false {
                    self?.activityIndicator.startAnimating()
                }

            } else {
                self?.activityIndicator.stopAnimating()

                //when load completed, set progress 0.0 in progressView.
                self?.progressView.setProgress(0.0, animated: false)

                //when load is completed, set title of loaded page as NavigationTitle.
                self?.title = webView.title
            }
        }))

        observers.append(webView.observe(\.estimatedProgress,
                                         options: .new,
                                         changeHandler: {[weak self] (_, change) in

                                            guard change.newValue != nil else { return }
                                            self?.progressView.setProgress(Float(change.newValue!), animated: true)
        }))
    }

    override func viewWillAppear(_ animated: Bool) {
        loadWebView()
    }

    private func loadWebView() {
        let url = URL(string: "https://www.google.com/")
        let urlRequest = URLRequest(url: url!)
        webView.load(urlRequest)
        progressView.setProgress(0.1, animated: true)

    }
}

observeメソッド内で、値の変化時に必要な処理を全て書かなければいけないので、少し読みづらくなっています。

RXSwift + RxWebKitで実装

ViewController.swift
import UIKit
import WebKit
import RxSwift
import RxCocoa
import RxOptional
import RxWebKit

class ViewController: UIViewController {

    @IBOutlet weak var webView: WKWebView!
    @IBOutlet weak var progressView: UIProgressView!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!

    private let disposeBag = DisposeBag()

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

        setupWebView()
    }

    private func setupWebView() {
        //define observer
        let loadingObservable = webView.rx.loading
                                .share()

        //display or hide progress bar
        loadingObservable
            .map { return !$0 }
            .observeOn(MainScheduler.instance)
            .bind(to: progressView.rx.isHidden)
            .disposed(by: disposeBag)

        //activity indicator
        loadingObservable.subscribe(onNext: {[weak self] (isLoading) in
            if isLoading {
                //start animating
                if self?.activityIndicator.isAnimating == false {
                        self?.activityIndicator.startAnimating()
                }
            } else {
                self?.activityIndicator.stopAnimating()
            }
            }).disposed(by: disposeBag)

        //navigationbar title
        loadingObservable
            .map { [weak self] _ in return self?.webView.title }
            .bind(to: navigationItem.rx.title)
            .disposed(by: disposeBag)

        //progress bar
        webView.rx.estimatedProgress
            .map { return Float($0) }
            .observeOn(MainScheduler.instance)
            .bind(to: progressView.rx.progress)
            .disposed(by: disposeBag)
    }

    override func viewWillAppear(_ animated: Bool) {
        loadWebView()
    }

    private func loadWebView() {
        let url = URL(string: "https://www.google.com/")
        let urlRequest = URLRequest(url: url!)
        webView.load(urlRequest)
        progressView.setProgress(0.1, animated: true)

    }
}

ネストが浅くなり、読みやすくなりました。

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

iOSアプリのMultitasking対応について

はじめに

WWDC2019にて2020年4月までにMultitaskingをサポートすることが必須と発表されました。
そして今年1月、必須ではないにしてもMultitaskingをサポートすることを 強くお勧めする とアナウンスがありました。
もちろん対応しなければアプリをリジェクトされるわけではありませんが、今後Multitaskingが重要となっていくことは明らかです。
そこで今回はMultitaskingの対応方法および、注意すべき点についてまとめていきます。
iPhoneとiPadに適したユーザーインターフェイスの構築

Mutitasking設定方法

  1. Requires full screenにチェックが入っている場合は外します スクリーンショット 2020-03-28 15.17.59.png
  2. Device Orientationを4つ全ての方向に対応するようにチェックします スクリーンショット 2020-03-28 15.17.59 2 2.png
  3. もし、iPadのみで全ての方向に対応するようにする場合はGeneralDevice Orientationは弄らず、info.plistからSupported interface orientations (iPad)にて4つの方向全て対応するよう設定して下さい スクリーンショット 2020-03-28 15.18.32.png
  4. 最後に、もし起動画面をStoryboardではなくimageで作成している場合はLaunch Screen FileにてLaunch用のStoryboardを作成して設定します スクリーンショット 2020-03-28 15.17.59 2 3.png
  5. あとはiPadでビルドすればMutliTaskingに対応していることを確認することができます

Mutitasking対応方法

Mutitaskingの対応すると今までにはなかったSplit表示になる(画面分割される)タイミングなどでレイアウトを更新する必要が出てきます。
AutoLayoutにレイアウトの計算を全て任せることができていれば大きく対応する必要はありませんが、手動で計算をしている箇所等についてはこちらで意図的に再計算を走らせる必要があります。
もちろん、レイアウトを手動計算する方法で対応するよりもAutolayoutにて対応するのが理想ではあるとは思いますが、そう簡単に対応できるわけではありませんので、手動でのレイアウトが必要な場合の対応方法について記載します。

レイアウト再計算処理タイミングの見直し

ViewControllerのLifececle関数にてMultitaskingによる画面サイズの変更を検知してくれるのは大きく以下の2つです。

viewWillTransition(to:with:)

Multitasking(および画面回転)による画面サイズの変更はviewWillTransition(to:with:)にて検知できます。
また、サイズ変更後のアプリのWindowサイズは引数のsizeの中に格納されています。

/// 画面回転時、およびMultitaskingによる画面サイズ変更時に呼ばれる
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    coordinator.animate(alongsideTransition: { _ in
        // 遷移アニメーション時に行う処理を記述
    }, completion: { _ in
        // 遷移終了後に行う処理を記述
    })
}

viewDidLayoutSubviews()

viewDidLayoutSubviewsでも画面サイズの変更を検知することができます。
ただし、こちらはMultitaskingによる画面サイズ変更のみでなく、レイアウトが更新されるタイミング全てで呼ばれますので、コストがかなりかかってしまう可能性があります。
使用する場合は慎重に決めて下さい。

UIScreen.main.boundsについて

UIScreen.main.bounds、幅や高さ計算で何かと便利で使ってしまいがちですが、これを使用してframe計算をしている箇所についてはほぼ全て修正する必要があります。
UIScreen.main.boundsは、あくまで 端末のサイズ を取得するモノで、 アプリの表示サイズ を取得するモノではありません。
SplitViewで表示される際はアプリの表示サイズが端末サイズよりも小さい状態で表示される場合がありますので、UIScreen.main.boundsを使用しているとレイアウトが崩れる場合があります。
その場合は下記の方法にて、現在表示されているアプリサイズ(Window)のboundsをとってくることができますので、必要に応じてUIScreen.main.boundsの箇所を書き換えることで対応できます。

/// アプリの現在表示されているWindow(KeyWindow)のサイズを取得してくる
UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.bounds

/// 下記でも同様の結果を取得できるが、keyWindowがiOS13にてdisabledとなっているので使用しない
UIApplication.shared.keyWindow?.bounds

おわりに

以上で基本的にはMultitaskingに対応することができると思います。
対応自体は比較的単純なのですが、規模が大きいアプリはカバーする画面数が多く大変になりがちです。
しかし、今後Multitaskingの重要性も増していくことと思いますので、是非この機会に対応してみてはいかがでしょうか?

参考文献

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

コミットメッセージに特定の文字列が含まれていれば、ある処理を行うを実現する

はじめに

この記事では、CircleCI で「コミットメッセージに特定の文字列が含まれていれば、ある処理を行いたい」場合の実現方法について紹介します。結論としては、circleci-agent step halt コマンドを利用することで比較的シンプルに実現できます。

今回は、CircleCI 2.1 で動作確認を行っています。

実現したかったこと

これまで、iOS プロジェクトにおいて以下のルールでワークフローを定義していました。

  • master 以外のブランチは、Unit test を実行する
  • master ブランチは、Unit test に加えて、UI testApp Store Connect へのデプロイ を実行する

これに加えて、新たに以下のルールを追加しました。

  • master 以外のブランチは、コミットメッセージに「kick-ui」が含まれていれば、 Unit test のあとに UI test を実行する

定義したワークフローの全体像

masterブランチ以外のWorkflow.png
図: master ブランチ以外の Workflow
masterブランチのWorkflow.png
図: master ブランチの Workflow

yaml の全体像

※ 各 job 内で行っている処理(呼び出している commands)については省略して例示します。

version: 2.1

jobs:
  unit_test:
    <<: *defaults
    steps:
      - unit-test

  run_ui_test_as_needed:
    <<: *defaults
    steps:
      - checkout
      - run:
          name: Decide whether to run ui-test, depending on the content of the commit message.
          command: |
            COMMIT_MESSAGE=$(git log -1 HEAD --pretty=format:%s)
            TRIGGER_MATCH_PATTERN="^.*kick-ui.*$"
            if [[ ${COMMIT_MESSAGE} =~ ${TRIGGER_MATCH_PATTERN} ]]; then
              echo "Continue to run ui_test, as the commit message contains kick-ui."
            else
              echo "Since the commit message does not include kick-ui, this job will be successfully terminated."
              circleci-agent step halt
            fi
      - ui_test

  ui_test:
    <<: *defaults
    steps:
      - ui_test

  deploy_appstore:
    <<: *defaults
    steps:
      - deploy

workflows:
  build-workflow:
    jobs:
      - unit_test
      - run_ui_test_as_needed:
          requires:
            - unit_test
          filters:
            branches:
              ignore: 
                - master
      - ui_test:
          filters:
            branches:
              only:
                - master
      - deploy_appstore:
          requires:
            - unit_test
            - ui_test
          filters:
            branches:
              only:
               - master

run_ui_test_as_needed job について

この job で 「コミットメッセージに「kick-ui」が含まれていれば、 Unit test のあとに UI test を実行する」という制御を実現しています。

run_ui_test_as_needed:
  <<: *defaults
  steps:
    - checkout
    - run:
        name: Decide whether to run ui-test, depending on the content of the commit message.
        command: |
          COMMIT_MESSAGE=$(git log -1 HEAD --pretty=format:%s)
          TRIGGER_MATCH_PATTERN="^.*kick-ui.*$"
          if [[ ${COMMIT_MESSAGE} =~ ${TRIGGER_MATCH_PATTERN} ]]; then
            echo "Continue to run ui_test, as the commit message contains kick-ui."
          else
            echo "Since the commit message does not include kick-ui, this job will be successfully terminated."
            circleci-agent step halt
          fi
    - ui_test

主に shell script で以下の処理を行っています。

  1. 直近のコミットメッセージを取得
  2. 正規表現で、特定したい文字列(今回であれば kick-ui)を定義
  3. 1. で取得したコミットメッセージに特定文字列が含まれているかを判定
    1. もし、含まれていれば何もしないことを echo するだけで、次のステップへ進む
    2. もし、含まれていなければその時点で job を正常終了する
  4. ui_test を実行する

3.2 で、job を正常終了させる際には、circleci-agent step halt コマンドを利用しています。このコマンドは、job を失敗させずに終了させることができるもので、ジョブを条件付きで実行する必要がある今回のようなケースに便利です。

Ending a Job from within a step | CircleCI

さいごに

今回は、master ブランチ以外(機能の実装途中など)でも気軽に ui_test を実行したいケースがあり、run_ui_test_as_needed job を追加しました。

条件付きで step の途中に job を終了させる circleci-agent step halt コマンドは、他にも応用できるケースがあると思うので、覚えておくと便利そうです。

参考

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

アプリ申請自動化とAppleID2ファクタ認証

はじめに

2019年にAppleDeveloperProgramのAppleIDは2ファクタ認証が必須となりました。(AccountHolderというもっとも権限を持つアカウントのみ)

この2ファクタ認証が有効になることにより、
CIサービスを用いたアプリ申請自動化にどのような影響が出るのか
またその回避策を改めてまとめた記事です。

この記事はfastlaneを中心に情報をまとめます。

2ファクタ認証 OFFが最もシンプル

はじめに述べてしまいますが、
もっともシンプルな解決策は2ファクタ認証を有効化していないアカウントを用意することです。
運用上、それが難しい方がこの記事でなんらかの解決策を見出して頂けると嬉しいです。

前提情報

アプリ申請の手順

今回はアプリ申請に関わる手順を以下のものとします。

  1. ipaファイルをAppstoreConnectにアップロード
  2. ストア情報のアップロード (説明文やスクショ等)
  3. 再コンパイルの完了を待つ
  4. アプリ審査提出
  5. 再コンパイル後のdSYMをダウンロード
  6. firebase等にdSYMをアップロード

上記手順1〜4はfastlaneのdeliver
5はdownload_dsyms
6はupload_symbols_to_crashlytics
で自動化することが可能です。

fastlaneの環境変数

アプリ申請の手順で用いられるfastlaneの環境変数を列挙しておきます。

環境変数名 概要
FASTLANE_USER アプリ申請可能なAppleID
FASTLANE_PASSWORD アプリ申請可能なAppleIDのパスワード
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD アプリケーション固有パスワード
FASTLANE_SESSION 2ファクタ認証成功時に得られるクッキー
有効期限1ヶ月

2ファクタ認証の回避方法

確認コードの入力を回避しインタラクティブモードでなくとも (CIサービス等で) アプリ申請を行う方法

恒久的に2ファクタ認証を回避

環境変数 FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD が設定されていると恒久的に2ファクタ認証を回避することができます。
ただし、この方法が有効なのは ipaファイルをAppstoreConnectにアップロード のみです。
それ以外の手順は恒久的な2ファクタ認証の回避ができないようです。
これはfastlaneの制限ではなく、Appleの用意したAPIの制限のようです。

一時的に2ファクタ認証

AppleIDのログインクッキーが環境変数 FASTLANE_SESSION に設定されていれば申請手順1〜5について2ファクタ認証を回避できます。
ただし、FASTLANE_SESSIONは有効期限が1ヶ月と限られており、定期的に再認証が必要となります。

追加情報

FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORDの生成方法

  1. Appleアカウントの管理にアクセス
  2. セキュリティ項目のパスワード生成をクリック スクリーンショット 2020-03-28 10.51.08.png
  3. 任意の名前をつける
    スクリーンショット 2020-03-28 10.51.17.png

  4. 得られるパスワードが FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD として利用可能
    スクリーンショット 2020-03-28 10.51.27.png

FASTLANE_SESSIONの生成方法

fastlane spaceship を利用します。
このspaceshipはAppstoreConnectへの認証やAppstoreConnectAPIラッパーとなっており、
deliverやdownload_dsymsの内部で利用されています。

以下のコマンドを実行すると、
パスワードの入力や確認コードの入力が求められます。

fastlane spaceauth -u user@email.com

認証に成功すると、~/.fastlane/user@email.com/cookieにクッキーが保存されます。
このファイルの内容を環境変数 FASTLANE_SESSION として利用することができます。

まとめ

  1. ipaファイルをAppstoreConnectにアップロード のみ、環境変数 FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORDで2ファクタ認証を回避可能。
  2. それ以外の操作を自動化したければ 環境変数 FASTLANE_SESSIONで2ファクタ認証を回避可能。
  3. ただし、FASTLANE_SESSIONは有効期限が1ヶ月と限られているため、運用方法を検討する必要がある。

おまけ

ローカルマシンで2ファクタ認証し、生成したFASTLANE_SESSIONをパラメータにCIサービスとトリガーしてみました。

Bitriseをトリガーする

以下のようにキックすることを想定
fastlane trigger_bitrise user:{AppleID}

    lane :trigger_bitrise do |options|
        apple_id = options[:user]
        # ログイン試行
        Spaceship::SpaceauthRunner.new(username: apple_id).run
        fastlane_session = ""
        # ローカルファイルに保存されたCookieを取得
        File.open("#{ENV['HOME']}/.fastlane/spaceship/#{apple_id}/cookie") do |file|
            fastlane_session = file.read
        end

        uri = URI.parse("https://api.bitrise.io/v0.1/apps/#{app_slug}/builds")

        http = Net::HTTP.new(uri.host, uri.port)
        http.use_ssl = true
        http.verify_mode = OpenSSL::SSL::VERIFY_NONE

        request = Net::HTTP::Post.new(uri.request_uri)
        request["Authorization"] = "#{bitrise_token}"

        data = {
          "build_params" => {
              "branch" => "develop",
              "environments" => [
                {
                    # FASTLANE_SESSIONを環境変数に設定
                    "is_expand": false,
                    "mapped_to": "FASTLANE_SESSION",
                    "value": fastlane_session
                }
              ]
          },
          "hook_info" => {
            "type" => "bitrise"
          }
        }.to_json

        request.body = data
        response = http.request(request)
        puts response.code
    end

GitHubActionsをトリガーする

以下のようにキックすることを想定
fastlane trigger_github_action user:{AppleID}

トリガー時、GitHubのアクセストークンが必要ですが、これは repo 権限が付与されている必要があります。
また、httpリクエストでトリガーする場合はmasterブランチもしくはデフォルトブランチに適用されます。

    lane :trigger_github_action do |options|
        apple_id = options[:user]
        # ログイン試行
        Spaceship::SpaceauthRunner.new(username: apple_id).run
        fastlane_session = ""
        # ローカルファイルに保存されたCookieを取得
        File.open("#{ENV['HOME']}/.fastlane/spaceship/#{apple_id}/cookie") do |file|
            fastlane_session = file.read
        end

        uri = URI.parse("https://api.github.com/repos/#{github_org}/#{repository}/dispatches")

        http = Net::HTTP.new(uri.host, uri.port)
        http.use_ssl = true
        http.verify_mode = OpenSSL::SSL::VERIFY_NONE

        request = Net::HTTP::Post.new(uri.request_uri)
        request["Authorization"] = "token #{github_token}"

        data = {
      # event_typeはyamlファイルと一致させる
          "event_type" => "test",
          "client_payload" => {
            # FASTLANE_SESSIONをパラメータに設定
            "fastlane_session" => fastlane_session
          }
        }.to_json

        request.body = data
        response = http.request(request)
        puts response.code
    end

GitHub Actionsのyaml

name: appstore_connect_login

on:
  # httpリクエストでトリガーする場合は repository_dispatch
  repository_dispatch:
    type: ["test"] # typeに指定した文字列をリクエストパラメータ"event_type"と一致させる
jobs:
  appstore_connect_login:
    name: appstoreconnect login
    runs-on: ubuntu-latest
    env:
      FASTLANE_USER: ${{ secrets.FASTLANE_USER }} # secretsに保存されている変数を取り出す
      FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }} # secretsに保存されている変数を取り出す
      FASTLANE_SESSION: '${{ github.event.client_payload.fastlane_session }}' # トリガー時のパラメータを変数に取り出す
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-ruby@v1
        with:
          ruby-version: '2.6.5'
      - name: install fastlane
        run: gem install bundle; bundle install
      - name: check env
        run: |
          echo ${FASTLANE_USER:?} > /dev/null
          echo ${FASTLANE_PASSWORD:?} > /dev/null
          echo ${FASTLANE_SESSION:?} > /dev/null
      - name: exec fastlane
        run: bundle exec fastlane appstore_connect_login

参考記事
https://help.github.com/ja/actions/reference/events-that-trigger-workflows#
https://docs.fastlane.tools/best-practices/continuous-integration/
https://github.com/fastlane/fastlane/tree/master/spaceship

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