20191124のiOSに関する記事は11件です。

[Xcode]Wi-Fiでビルドする[実機]

開発中のiosアプリを実機(iPhone)でビルドする際に、USBケーブルを繋がずWi-Fiでビルドする方法です。

開発環境

macOS Catalina V.10.15.1
Xcode V.11.2.1

やり方

1、最初はUSBケーブルで実機(iPhone)とMacを繋ぎます。
(このデバイスを信用しますか?と聞かれるので、「OK」)

2、タブバーにある「Window」をクリックし、さらに「Devices and Simulators」をクリックします。
(ショートカットキーで⬆️+ ⌘ + 2 でもいい)

3、すると、実機(iPhone)の情報が表示されます。そこにある「Connect via network」のチェックボックスにチェックを入れます。

これで完了です。試しにUSBを抜いてビルドしてみてください、正常にビルドできるはずです。

ただ、ビルドの速度はどうなんでしょう、、、やっぱり有線の方が速いかな?:thinking:

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

【Swift/iOS】CoreLocationで緯度・経度を取得して位置情報を表示する

概要

CoreLocationで緯度・経度を取得して位置情報を表示する方法を説明します。

確認バージョン

Xcode 11.2.1
Swift 5.1.2
iOS 13.2.3

CoreLocation

ライブラリ

TARGET -> Build Phase -> Link Binary With Libraries
CoreLocation.frameworkを追加
CoreLocationライブラリ

Info.plist

Privacy - Location When In Use Usage Description
位置情報許可のアラート画面に表示される説明を追加します。

実装

  1. 情報表示用のUILabelを追加
  2. 位置情報を利用許可状態を判定
  3. 位置情報の更新開始
  4. CLLocationデータを逆ジオコーディングして位置情報を取得
  5. 位置情報が更新されるとUILabelに設定
import UIKit
import CoreLocation

class ViewController: UIViewController, CLLocationManagerDelegate {
    let locationManager = CLLocationManager()
    let geocoder = CLGeocoder()
    let text = [ "緯度", "経度", "国名", "郵便番号", "都道府県", "郡", "市区町村", "丁番なしの地名", "地名", "番地" ]
    var item: [ UILabel ] = []
    var location: [ UILabel ] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        //サイズ
        let width = self.view.frame.width / 2
        let height = self.view.frame.height / CGFloat( self.text.count + 1 )

        //ラベル
        for ( i, text ) in text.enumerated() {
            //項目
            self.item.append( UILabel() )
            self.item.last!.frame.size = CGSize( width: width, height: height )
            self.item.last!.frame.origin = CGPoint( x: 0, y: height * CGFloat( i + 1 ) )
            self.item.last!.textAlignment = .center
            self.item.last!.text = text
            self.view.addSubview( self.item.last! )

            //データ
            self.location.append( UILabel() )
            self.location.last!.frame.size = CGSize( width: width, height: height )
            self.location.last!.frame.origin = CGPoint( x: width, y: height * CGFloat( i + 1 ) )
            self.location.last!.textAlignment = .center
            self.view.addSubview( self.location.last! )
        }

        //ロケーションマネージャ
        self.locationManager.requestWhenInUseAuthorization()
        let status = CLLocationManager.authorizationStatus()
        if status == .authorizedWhenInUse {
            self.locationManager.delegate = self
            self.locationManager.distanceFilter = 10
            self.locationManager.startUpdatingLocation()
        }
    }

    func locationManager( _ manager: CLLocationManager, didUpdateLocations locations: [ CLLocation ] ) {
        //表示更新
        if let location = locations.first {
            //緯度・経度
            self.location[0].text = location.coordinate.latitude.description
            self.location[1].text = location.coordinate.longitude.description

            //逆ジオコーディング
            self.geocoder.reverseGeocodeLocation( location, completionHandler: { ( placemarks, error ) in
                if let placemark = placemarks?.first {
                    //位置情報
                    self.location[2].text = placemark.country
                    self.location[3].text = placemark.postalCode
                    self.location[4].text = placemark.administrativeArea
                    self.location[5].text = placemark.subAdministrativeArea
                    self.location[6].text = placemark.locality
                    self.location[7].text = placemark.subLocality
                    self.location[8].text = placemark.thoroughfare
                    self.location[9].text = placemark.subThoroughfare
                }
            } )
        }
    }
}

Githubのソースコードはこちら

※最新のコミットは下記の記事で説明するソースコードに更新しました。
【Swift/iOS】CoreLocationで取得した位置情報を住所に変換する

実行

実行すると位置情報が表示されます。
シミュレータで実行する場合には場所を指定して下さい。
位置情報シミュレータ

取得できる情報は言語設定にしたがってローカライズされています。

日本 JAPAN アメリカ合衆国 USA

参考

【CoreLocation】位置情報を取得する
[iOS] MapKitを使って”ジオコーディング・逆ジオコーディング”をやってみる

開発アプリ

NomiReco - お酒好きなあなたをサポート

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

【Flutter】プラグインを使用した時に出るSwiftバージョン指定エラーの解決

問題

下記エラーの対処法です。

Flutterで特定のプラグインを使用した状態で、iOSでデバッグしようとすると発生します。

`app_review` does not specify a Swift version and none of the targets (`Runner`) integrating it have the `SWIFT_VERSION` attribute set. Please contact the author or set the `SWIFT_VERSION` attribute in at least one of the targets that integrate this pod.

app_reviewの部分は使用するプラグインによって変わります。

SWIFT_VERSIONを指定しろ!!と言っています。

もしかしたら、エラーメッセージの中に下の奴が黄色で強調されていて、エラーの本体のように見えてしまうかもしれませんが、上の奴が本体です。

WARNING: CocoaPods requires your terminal to be using UTF-8 encoding.

注意

この記事の解決策を試す際には必ず

flutter clean

を実行してからビルドするようにしてください。

でないと、全然関係ないエラーを引き起こして混乱します。

解決策

ステップ1

まずはこちらの記事を、作者の方に感謝しながら参照して、XCode経由で、デフォルトのSwiftファイルとデフォルトのBridgingHeaderファイルを作成します。

https://qiita.com/ko2ic/items/f082f07df8a2aca6beed

これで解決すれば完了です。

ステップ2

それでも解決しない場合の解決策はこちらのissueのこのコメントです。

https://github.com/flutter/flutter/issues/16049#issuecomment-382629492

リンク先のソースコードをそのまま引用します(引用日:2019-11-24)

ios/Podfile
target 'Runner' do
  use_frameworks! # <--- add this
  ...
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['ENABLE_BITCODE'] = 'NO'
      config.build_settings['SWIFT_VERSION'] = '3.2' # <--- add this
    end
  end
end

ios/Podfileの適切な場所に、

use_frameworks!

config.build_settings['SWIFT_VERSION'] = '3.2'

の二行を追加してください。

これで解決すれば完了です。

ステップ3

ただ、おそらく、下記のようなエラーメッセージが出ると思います。

** BUILD FAILED **
Xcode's output:
↳
The “Swift Language Version” (SWIFT_VERSION) build setting must be set to a supported value for targets which use Swift. Supported values are: 4.0, 4.2, 5.0. This setting can be set in the build settings editor.

この場合は、Swiftのバージョンをエラーメッセージで指定されている番号に書き換えてください。

config.build_settings['SWIFT_VERSION'] = '3.2'

config.build_settings['SWIFT_VERSION'] = '5.0'

これで解決するはずです。少なくとも私は解決したんですよ😃

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

FloatingPanelで高さを調整しようとして詰まった

この記事について

FloatingPanel というハーフモーダルビューの実装を数行でできてしまうツールを使おうとして、パネルの高さを調整しようとして詰まった 点のメモ。

Problem

FloatingPanelLayout を以下のような形で実装してパネルの高さを調整しようとしたら、Panジェスチャーが指の方向とは真逆に動いたり、変な部分でカクついたりしてして挙動がおかしかった。
(以下は ドキュメント より抜粋)

class ViewController: UIViewController, FloatingPanelControllerDelegate {
    ...
    func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout? {
        return MyFloatingPanelLayout()
    }
}

class MyFloatingPanelLayout: FloatingPanelLayout {
    public var initialPosition: FloatingPanelPosition {
        return .tip
    }

    public func insetFor(position: FloatingPanelPosition) -> CGFloat? {
        switch position {
            case .full: return 16.0 // A top inset from safe area
            case .half: return 216.0 // A bottom inset from the safe area
            case .tip: return 44.0 // A bottom inset from the safe area
            default: return nil // Or `case .hidden: return nil`
        }
    }
}

Solution

floatingPanelController.delegate = selffloatingPanelController.set() , floatingPanelController.addPanelより後で読んでしまっていたのを、 で呼ぶようにしたら直った。

バグを吐いていた例

        let contentVC = ContentViewController()
        fpc.set(contentViewController: contentVC)

        fpc.addPanel(toParent: self)

        fpc.delegate = self

直った例

        fpc.delegate = self

        let contentVC = ContentViewController()
        fpc.set(contentViewController: contentVC)

        fpc.addPanel(toParent: self)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【iOS】よくある横スクロールをStoryboardだけで作る

アプリのチュートリアル的な画面でよく見る、横にページめくりする感じのViewを実装します。

こんな感じのものです。

これをコードを全く書かずに作ります。サクッと作りたい時におすすめです。

それでは順を追って説明します。

UIScrollView、UIStackView(Horizontal)を配置

1.gif

普通にスクロールビューを置いて制約(画面いっぱい)を設定し、中にスタックを置いています。
横スクロールにしたいのでスタックはHorizontalにし、スクロールビューとスタックビューの高さをあわせます。
(Stackをverticalに、横幅をあわせれば普通に縦スクロールになります。)

UIScrollViewについて詰まったら(多分)いちばんシンプルなUIScrollViewの実装も見てみてください。

Stackの中にUIViewを配置

各ページとなるViewを配置します。
2.gif

配置したViewに制約を設定

3.gif

Viewの横幅を画面サイズ(UIScrollView)と同じにする制約をつけます。
(UIStackViewで高さは決まっているので横だけ決めればOKです。)
ここまでで Viewの個数 × 画面幅分 のスクロールができるようになります。

UIScrollViewの「Paging Enabled」を有効に

ぺーじんぐ.png

このままだとただの横スクロールなので、
ページめくりのような動きにするために「Paging Enabled」を有効にします。
バウンス(端までスクロールした時に跳ねる感じのやつ)が不要であれば「Bounce On Scroll」も無効にしましょう。

後は、先程いっぱい置いたViewに適宜部品を置いてチュートリアルを完成させましょう。
(今回は違いがわかるように背景色だけ変更しております。)

以上で完成です。

余談

少しだけコードを記述すれば、UIPageControllerも追加できます。

こんな感じ。

追加したコードは以下です。
レイアウトにはUIPageControlを追加し、ページ数だけ設定してあります。

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var scroll: UIScrollView!
    @IBOutlet weak var pageControl: UIPageControl!

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

    override func viewWillAppear(_ animated: Bool) {
        self.scroll.delegate = self
    }
}

extension ViewController:UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let index = Int(round(scrollView.contentOffset.x / scrollView.frame.width))
        self.pageControl.currentPage = index
    }
}

不明点、不足点、改善点等ございましたらお教えいただけますと幸いです。
ありがとうございました。

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

[はじめてのiOSアプリ]xcodeで地図アプリを作成(その1)

はじめに

iOSアプリを作ってみたいけど
何から始めて良いのかわからない

とりあえず、
「やってみました」記事を参考に
地図アプリを真似てみようと思う

という記事の1回目です。

今回は、プロジェクトを作成します。

+αの目標

世の中に「やってみました」記事は、たくさんある。
多くの場合、同じことをすれば同じ結果を得ることができる。

しかし「なぜそのようなことをするのか?」を
解決できないから自分が成長しない。

『人が書いた「やってみました」記事を参考に
できるだけ「なぜ?」を解決しながらやってみよう』(汗)

機能リスト

  • 基本機能として、このような機能があればいいかな?

    • 地図を表示
    • GPS(corelocation)で現在位置取得し表示
    • GPSと地図表示を連動
  • 追加機能としては、これ。

    • 縮尺を変更可能
    • GPS 精度変更
    • 逆 geocoding で地名表示

プロジェクト作成

  1. Xcode11 を起動
    • 【なぜ?】
      • iOS アプリを開発するには xcode を使う必要があるから
  2. 新規プロジェクトを作成
    • [Create a new Xcode project]を選択
    • 【なぜ?】
      • 新しいアプリケーションを作成するから
      • 新しいアプリケーションを作成するとき、最初に1回だけ行えばよい
  3. プロジェクト種別
    • [iOS]-[Single View App]を選択
      NewProject.png
    • 【なぜ?】
      • 今回の地図アプリは、1画面で実現できるから
  4. プロジェクト情報
    NewProjectOptions.png
    • [Product Name]
      • MyGpsMap
      • 【なぜ?】
        • 作ろうとしているアプリの名称として適切と思ったから
        • 自分が作成するアプリ名称で重複しない文字列
    • [Team]
      • Personal Team
      • 【なぜ?】
        • None ではない、自分が所属している Team を選択
        • 実は良くわかっていない
    • [Organization Name]
      • shinobee
      • 【なぜ?】
        • 自分が所属する組織を示す文字列
        • 他人との重複は気にしなくて問題ない(と思う)
        • 実は良くわかっていない
    • [Organization Identifier]
      • com.zen-zee.shinobee
      • 【なぜ?】
        • 他の人と重複しない文字列
          • 今回は、自分が持っているドメイン+username を使用
        • ドメインを持っていなければどうすれば良い?
          • com.icloud.YourName で良いのか?
        • 適当につけて、他人と重複したらどうなるのか?
    • [Bundle Identifier]
      • 変更できないので、そのまま
    • [Language]
      • Swift
      • 【なぜ?】
        • Swift を使いプログラムを書くから
    • [User Interface]
      • Storyboard を選択
      • 【なぜ?】
        • 従来からの UI 作成手段
        • Swift UI のこと良く知らない、使ったことがない
    • [Use Core Data]
      • チェックを入れる
      • 【なぜ?】
        • iOS アプリの標準的なフレームワークなので入れておけば良い
        • 実は良くわかっていない
    • [Use CloudKit]
      • チェックを入れない
      • 【なぜ?】
        • 今回は、iCloud を使わないから
    • [Include Unit Tests]
      • チェックを入れる
      • 【なぜ?】
        • 自動テスト関連
        • 将来も使わなかもしれないけど、基本的に無害なので入れておけば良い
    • [Include UI Tests]
      • チェックを入れる
      • 【なぜ?】
        • 自動テスト関連?
        • 将来も使わなかもしれないけど、基本的に無害なので入れておけば良い
        • 実は良くわかっていない
  5. プロジェクト情報保存フォルダ
    • ${HOME}/github/shinobee/
    • [Create Git repository on my Mac]にチェックを入れる
      • 【なぜ?】
        • ソースのバージョン管理に git を使用するから
        • 将来も使わなかもしれないけど、基本的に無害なので入れておけば良い
  6. 作成したプロジェクト
    ProjectMyGpsMap.png

今回の到達点

  • これで良いのかわからないけど、とりあえず、xcodeのプロジェクトまで作成できた

連載

  1. [はじめてのiOSアプリ]xcodeで地図アプリを作成(その1)
  2. [はじめてのiOSアプリ]xcodeで地図アプリを作成(その2)
  3. [はじめてのiOSアプリ]xcodeで地図アプリを作成(その3)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS端末の機能を使ったり、状態を管理したりしたいときに読む記事がこちら!

最近クロス開発プラットフォームの実用性を検証していたのですが、その中で、iOSネイティブならではの機能ってクロス開発でどう実装するんだろう?そもそもどんなのがあったっけ?とふと思うことがありましたので、iOSネイティブならではのiOS端末と密接に関わる機能の実装サンプルをまとめてみました。

開発環境はXcode11.0、Swift5.0で、最小構成で実装しています。
正直この記事を見た方が早いな...と思った項目に関してはリンクを掲載させていただいています。どの記事もわかりやくまとまっていて、とても勉強になるものばかりです。

目次

カメラ・アルバム

端末のカメラ・アルバムを使用する場合の実装です。ここでは写真・動画を撮影してアルバムに保存するまでを実装しています。

1.ユーザーに端末機能の使用許可を求めるため、Info.plistへ以下のKeyを追加

  • Privacy - Camera Usage Description → カメラの使用
  • Privacy - Photo Library Usage Description → アルバムの使用
  • Privacy - Photo Library Additions Usage Description → アルバムへの保存
  • Privacy - Microphone Usage Description → (動画を撮影する際の)マイクの使用

2.コーディング

class ImagePickerViewController: UIViewController {
    let imagePicker = UIImagePickerController()
    let photoTypes = ["public.image"]
    let movieTypes = ["public.movie"]

    /*省略*/

    @objc func tapPhotoCameraButton() {
        pick(.photo)
    }

    @objc func tapMovieCameraButton() {
        pick(.video)
    }
}

extension ImagePickerViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
    // 写真・動画を撮影
    func pick(_ mode: UIImagePickerController.CameraCaptureMode) {
        // 写真・動画のソースが使用可能かどうかをチェック
        guard UIImagePickerController.isSourceTypeAvailable(.camera) else { return }
        imagePicker.delegate = self
        // 写真・動画のソースの種類を設定
        imagePicker.sourceType = .camera
        // 写真・動画の切り替えを設定
        switch mode {
        case .photo: imagePicker.mediaTypes = photoTypes
        case .video: imagePicker.mediaTypes = movieTypes
        @unknown default: fatalError()
        }
        imagePicker.cameraCaptureMode = mode
        // 写真・動画の編集可否を設定
        imagePicker.allowsEditing = true
        // 撮影画面を表示
        present(imagePicker, animated: true)
    }

    // 撮影後の処理
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        switch picker.mediaTypes {
        case photoTypes:
            // 写真を取得
            guard let pickedImage = info[.originalImage] as? UIImage else { return }
            // アルバムに保存
            UIImageWriteToSavedPhotosAlbum(pickedImage, nil, nil, nil)
        case movieTypes:
            // 動画を取得
            guard let movieURL = info[.mediaURL] as? URL else { return }
            // アルバムに保存
            UISaveVideoAtPathToSavedPhotosAlbum(movieURL.relativePath, nil, nil, nil)
        default: break
        }
        // 撮影画面を非表示
        dismiss(animated: true)
    }
}

位置情報

端末の位置情報を使用する場合の実装です。ここでは住所と方角(端末の向き)を取得します。

1.ユーザーに位置情報の使用許可を求めるため、Info.plistへ以下のKeyを追加

  • Privacy - Location When In Use Usage Description → アプリ使用時のみ使用
  • Privacy - Location Always and When In Use Usage Description → 常に使用

2.コーディング

import CoreLocation
import UIKit

class LocationInfoReaderViewController: UIViewController {
    var locationManager: CLLocationManager?

    /*省略*/
}

extension LocationInfoReaderViewController: CLLocationManagerDelegate {
    // 位置情報取得の許可状況に応じた処理
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        switch status {
        case .authorizedAlways: return // 常に許可されている場合の処理
        case .authorizedWhenInUse: locationManager?.requestAlwaysAuthorization() // アプリ使用時のみ許可されている場合の処理
        case .restricted, .denied: return // 許可されていない場合の処理
        case .notDetermined: locationManager?.requestWhenInUseAuthorization() // 許可するかどうかが設定されていない場合の処理
        default: fatalError()
        }
    }

    enum UpdatingState {
        case locationStarted
        case locationStopped
        case headingStarted
        case headingStopped
    }

    // 位置情報の更新を開始・停止
    func switchUpdatingState(to state:  UpdatingState) {
        // 位置情報が使用可能かどうかをチェック
        guard CLLocationManager.locationServicesEnabled() else { return }
        switch state {
        case .locationStarted: locationManager?.startUpdatingLocation() // 緯度・経度の更新を開始
        case .locationStopped: locationManager?.stopUpdatingLocation() // 緯度・経度の更新を停止
        case .headingStarted: locationManager?.startUpdatingHeading() // 方角の更新を開始
        case .headingStopped: locationManager?.stopUpdatingHeading() // 方角の更新を停止
        }
    }

    // 位置情報の更新を開始した場合、更新される度に位置情報が下記のデリゲートメソッドに渡されます

    // 現在地の住所を取得
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        // 緯度・経度を取得
        guard let location = locations.last else { return }
        // 緯度・経度から場所の詳細情報を取得
        CLGeocoder().reverseGeocodeLocation(location) { (placemarks, _) in
            guard let placemark = placemarks?.last else { return }
            self.addressInfoTextView.text = """
            \(placemark.postalCode ?? "" /*郵便番号*/)\n
            \(placemark.administrativeArea ?? ""/*都道府県*/)\n
            \(placemark.locality ?? ""/*市町村*/)\n
            \(placemark.thoroughfare ?? ""/*地区*/)\n
            \(placemark.subThoroughfare ?? ""/*番地*/)
            """
        }
    }

    // 方角(端末の向き)を取得
    func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
        // 360°を45°ずつ8分割して方角を取得
        let direction = { () -> String in
            switch (newHeading.magneticHeading / 360) * 8 {
            case 0.0 ..< 0.5, 7.5 ..< 8.0: return "北"
            case 0.5 ..< 1.5: return "北西"
            case 1.5 ..< 2.5: return "西"
            case 2.5 ..< 3.5: return "南西"
            case 3.5 ..< 4.5: return "南"
            case 4.5 ..< 5.5: return "南東"
            case 5.5 ..< 6.5: return "東"
            case 6.5 ..< 7.5: return "北東"
            default: fatalError()
            }
        }
        directionInfoLabel.text = direction()
    }
}

ちなみに特定の場所の住所を知りたい場合は、CLGeocoderクラスのgeocodeAddressStringメソッドの引数に地名(例:「東京タワー」)の文字列を渡してあげて緯度・経度を取得し、その緯度・経度を上の例と同様にreverseGeocodeLocationメソッドの引数に渡してあげると住所が取得できます。例えばUITextFieldと連携した場合は以下のようになりますね。

extension LocationInfoReaderViewController: UITextFieldDelegate {
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        textField.resignFirstResponder()
        guard let place = textField.text else { return true }
        let geocoder = CLGeocoder()
        geocoder.geocodeAddressString(place) { (placemarks, _) in
            guard let location = placemarks?.last?.location else { return }
            geocoder.reverseGeocodeLocation(location) { (placemarks, _) in
                guard let placemark = placemarks?.last else { return }
                self.addressInfoTextView.text = """
                \(placemark.postalCode ?? "")\n
                \(placemark.administrativeArea ?? "")\n
                \(placemark.locality ?? "")\n
                \(placemark.thoroughfare ?? "")\n
                \(placemark.subThoroughfare ?? "")
                """
            }
        }
        return true
    }
}

端末イベント

端末で発生するイベントを監視・通知する場合の実装です。

監視

NotificationCenterクラスを使います。監視対象のイベントは色々とありますが、ここでは例としてアプリのライフサイクルと端末キーボードのイベントを監視しています。

class EventObserverViewController: UIViewController {
    /*省略*/

    // 監視を開始
    func setUpNotification() {
        [(#selector(appWillResignActive), UIApplication.willResignActiveNotification),
         (#selector(appDidEnterBackground), UIApplication.didEnterBackgroundNotification),
         (#selector(appWillEnterForeground), UIApplication.willEnterForegroundNotification),
         (#selector(appDidBecomeActive), UIApplication.didBecomeActiveNotification),
         (#selector(keyboardWillBecomeShown), UIApplication.keyboardWillShowNotification),
         (#selector(keyboardDidBecomeShown), UIApplication.keyboardDidShowNotification),
         (#selector(keyboardWillBecomeHidden), UIApplication.keyboardWillHideNotification),
         (#selector(keyboardDidBecomeHidden), UIApplication.keyboardDidHideNotification)]
        .forEach { (action, notificationName) in
            NotificationCenter.default.addObserver(self, selector: action, name: notificationName, object: nil)
        }
    }

    // 監視を停止(iOS9以降この処理は不要)
    func tearDownNotification() {
        NotificationCenter.default.removeObserver(self)
    }
}

// アプリライフサイクル関連のイベント発生時の処理
extension EventObserverViewController {
    @objc func appWillResignActive() {
        print("アプリがバックグランドに行きそうです")
    }

    @objc func appDidEnterBackground() {
        print("アプリがバックグランドに行きました")
    }

    @objc func appWillEnterForeground() {
        print("アプリがアクティブ状態になりそうです")
    }

    @objc func appDidBecomeActive() {
        print("アプリがアクティブ状態になりました")
    }
}

// キーボード関連のイベント発生時の処理
extension EventObserverViewController {
    @objc func keyboardWillBecomeShown() {
        print("キーボードが表示されそうです")
    }

    @objc func keyboardDidBecomeShown() {
        print("キーボードが表示されました")
    }

    @objc func keyboardWillBecomeHidden() {
        print("キーボードが閉じられそうです")
    }

    @objc func keyboardDidBecomeHidden() {
        print("キーボードが閉じられました")
    }
}

通知 - プッシュ通知

こちらをご参照ください。

通知 - バッジ付与

標準フレームワークのUserNotificationsを使ってアプリのアイコンにバッジを付与します。

import UIKit
import UserNotifications

class UserNotifierViewController: UIViewController {
    var numberOfBadge = 0 {
        // この変数の更新後にバッジを付与
        didSet { UIApplication.shared.applicationIconBadgeNumber = numberOfBadge }
    }

    /*省略*/

    // 通知を許可するかどうかを確認
    @objc func authorizeUserNotification() {
        let center = UNUserNotificationCenter.current()
        center.requestAuthorization(options: [.alert, .badge, .sound]) { (granted, error) in
            print("通知が\(granted ? "許可" : "拒否")された")
        }
    }

    // バッジ数を+1
    @objc func plusBadge() {
        numberOfBadge += 1
    }

    // バッジ数をリセット
    @objc func resetBadge() {
        numberOfBadge = 0
    }
}

ネットワーク

ネットワークの接続状況を監視する場合の実装です。ネットワークや後で説明するセンサーの中には上で触れたNotificationCenterクラスの監視対象になるものもありますが、ネットワークやセンサーは大きな概念なので項目を分けています。

また、ネットワーク監視の実装方法として標準フレームワークにあるSCNetworkReachabilityクラスを直接使ったやり方もありますが、APIが非常にわかりくいので、ここではそのAPIをわかりやすくラッピングしたReachability.swiftというライブラリを使います。メンテなどが行き届いているのでけっこう使われているのではないでしょうか。

1.ライブラリのインストール → https://github.com/ashleymills/Reachability.swift

2.コーディング

import Reachability
import UIKit

class NetworkObserverViewController: UIViewController {
    let reachability = try! Reachability()

    /*省略*/

    // ネットワークの監視を開始
    func setUpNetworkObserver() {
        NotificationCenter.default.addObserver(self, selector: #selector(reachabilityChanged(note:)), name: .reachabilityChanged, object: reachability)
        do{
            try reachability.startNotifier()
        }catch{
            print("ネットワーク監視ができません")
        }
    }

    // ネットワークの監視を停止
    func tearDownNetworkObserver() {
        reachability.stopNotifier()
        NotificationCenter.default.removeObserver(self, name: .reachabilityChanged, object: reachability)
    }

    // ネットワークの接続状況に応じた処理
    @objc func reachabilityChanged(note: Notification) {
        guard let reachability = note.object as? Reachability else { return }
        switch reachability.connection {
        case .wifi: print("Wifiでつながりました")
        case .cellular: print("セルラーでつながりました")
        case .unavailable: print("ネットワークが切れました")
        case .none: print("ネットワーク監視ができません")
        }
    }
}

センサー

端末のセンサーを使用して以下のイベントを検知する場合の実装です。

スクリーンの明るさ

class BrightnessObserverViewController: UIViewController {
    /*省略*/

    // スクリーンの明るさの監視を開始
    func setUpNotification() {
        NotificationCenter.default.addObserver(self, selector: #selector(updateBrightnessInfo(_:)), name: UIScreen.brightnessDidChangeNotification, object: nil)
    }

    // スクリーンの明るさの監視を停止(iOS9以降この処理は不要)
    func tearDownNotification() {
        NotificationCenter.default.removeObserver(self, name: UIScreen.brightnessDidChangeNotification, object: nil)
    }

    // 明るさの表示を更新
    @objc func updateBrightnessInfo(_ notification: Notification) {
        guard let screen = notification.object as? UIScreen else { return }
        brightnessInfoTextView.text = "\(String(format: "%.1f", screen.brightness * 100))%"
    }
}

こちらをご参照ください。

加速度

import CoreMotion
import UIKit

class AccelerationObserverViewController: UIViewController {
    let motionManager = CMMotionManager()

    /*省略*/

    @objc func startObservingAcceleration() {
        // 加速度センサーが使用可能かどうかをチェック
        guard motionManager.isAccelerometerAvailable else { return }
        // 加速度の更新間隔(秒)を設定
        motionManager.accelerometerUpdateInterval = 0.1
        // 加速度の更新を開始
        motionManager.startAccelerometerUpdates(to: OperationQueue.current!, withHandler: updateAcceleration())
    }

    @objc func stopObservingAcceleration() {
        // 加速度センサーが使用可能かどうかをチェック
        guard motionManager.isAccelerometerActive else { return }
        // 加速度の更新を停止
        motionManager.stopAccelerometerUpdates()
    }

    func updateAcceleration() -> CMAccelerometerHandler {
        return { (data, _) in
            // 加速度を更新中かどうかをチェック
            guard self.motionManager.isAccelerometerActive else { return }
            // 加速度(メートル毎秒毎秒)を取得
            guard let acceleration = data?.acceleration else { return }
            // 加速度を小数第一まで取得
            let formatted = { (acceleration: Double) -> String in
                return String(format: "%.1f", acceleration)
            }
            self.accelerationInfoTextView.text =  """
            x軸: \(formatted(acceleration.x))
            y軸: \(formatted(acceleration.y))
            z軸: \(formatted(acceleration.z))
            """
        }
    }
}

角加速度

import CoreMotion
import UIKit

class GyroObserverViewController: UIViewController {
    let motionManager = CMMotionManager()

    /*省略*/

    @objc func startObservingGyro() {
        // 角加速度センサーが使用可能かどうかをチェック
        guard motionManager.isGyroAvailable else { return }
        // 角加速度の更新間隔(秒)を設定
        motionManager.gyroUpdateInterval = 0.1
        // 角加速度の更新を開始
        motionManager.startGyroUpdates(to: OperationQueue.current!, withHandler: updateGyro())
    }

    @objc func stopObservingGyro() {
        // 角加速度センサーが使用可能かどうかをチェック
        guard motionManager.isGyroActive else { return }
        // 角加速度の更新を停止
        motionManager.stopGyroUpdates()
    }

    func updateGyro() -> CMGyroHandler {
        return { (data, _) in
            // 角加速度を更新中かどうかをチェック
            guard self.motionManager.isGyroActive else { return }
            // 角加速度(ラジアン毎秒毎秒)を取得
            guard let gyro = data?.rotationRate else { return }
            // 角加速度を小数第一まで取得
            let formatted = { (gyro: Double) -> String in
                return String(format: "%.1f", gyro)
            }
            self.gyroInfoTextView.text =  """
            x軸: \(formatted(gyro.x))
            y軸: \(formatted(gyro.y))
            z軸: \(formatted(gyro.z))
            """
        }
    }
}

シェイク

class ShakeObserverViewController: UIViewController {
    /*省略*/

    override func viewDidLoad() {
        super.viewDidLoad()
        // イベントを最初に検知するオブジェクトに設定
        becomeFirstResponder()
    }

    // イベントを最初に検知するオブジェクトになれるかどうかをチェック
    override var canBecomeFirstResponder: Bool {
        return true
    }

    // イベントを検知した際の処理
    override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        // イベントがシェイクかどうかをチェック
        guard motion == .motionShake else { return }
        shakeCount += 1
    }
}

歩数

こちらをご参照ください。

認証

端末で認証を使う場合の実装です。ここでは生体認証とSign In with Appleについて触れています。

生体認証

ここでは顔・指紋認証を使います。(マイiPhoneがiPhoneSEなので顔認証は確認できてません)

1.ユーザーに顔認証機能の使用許可を求めるため、Info.plistへ以下のKeyを追加

  • Privacy - Face ID Usage Description

2.コーディング

import LocalAuthentication
import UIKit

class BioAuthenticatorViewController: UIViewController {
    /*省略*/

    @objc func executeBioAuth() {
        let authSystem = LAContext()
        var authError: NSError?

        // 認証機能が使用可能かどうかをチェック
        guard authSystem.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &authError) else {
            print("顔・指紋認証を使用できません"); return
        }

        // 認証方式に応じた処理(iOS11以降で使用可)
        let authMessage = { () -> String in
            guard #available(iOS 11.0, *) else { return "認証機能を使用します" }
            switch authSystem.biometryType {
            case .faceID: return "顔認証を使用します"
            case .touchID: return "指紋認証を使用します"
            default: fatalError()
            }
        }

        // 認証を実行
        authSystem.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: authMessage()) { (granted, _) in
            print("認証に\(granted ? "成功" : "失敗")しました")
        }
    }
}

Sign In with Apple

こちらをご参照ください。

端末システム情報

端末のシステム情報を使用する場合の実装です。ここでは端末のモデル名とOS、バッテリー残量を取得します。

class DeviceInfoReaderViewController: UIViewController {
    /*省略*/

    func readDeviceInfo() -> String {
        // 使用中の端末を取得
        let device = UIDevice.current
        // バッテリー監視を設定
        device.isBatteryMonitoringEnabled = true
        return  """
        モデル: \(device.model)
        OS: \(device.systemName) \(device.systemVersion)
        バッテリー残量: \(device.batteryLevel * 100)%
        """
    }
}

ふぅ...以上です!

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

【iOS】iOS13のUISearchBarの新機能を試してみる

iOS13では
UISearchBarに関連する下記のクラスが追加されました。

UISearchTextField
https://developer.apple.com/documentation/uikit/uisearchtextfield

UISearchToken
https://developer.apple.com/documentation/uikit/uisearchtoken

UISearchTextFieldDelegate
https://developer.apple.com/documentation/uikit/uisearchtextfielddelegate

色々調べてみたのですが
あまり情報が出てきませんでした。

WWDC2019のセッションでもサンプルコードを出すと言っていたのですが
現在まだ見つけることができていません。
もし正式な情報の場所などご存知の方いらっしゃれば教えてください🙇🏻‍♂️

WWDC2019のセッション動画
https://developer.apple.com/videos/play/wwdc2019/224/?time=1276

そこで
今回は実際にコードを動かしながら
どういう風に活用できるのかを検討した結果を記載したいと思います。

各クラスの概要

UISearchTextField

こちらは元々非公開のプロパティでしたが
iOS13より公開され
UISearchBarのテキストフィールドのカスタマイズが
より簡単にできるようになりました。

下記のようにsearchTextFieldからアクセスできます。

var searchBar = UISearchBar()
searchBar.searchTextField.backgroundColor = .systemOrange
searchBar.searchTextField.textColor = .systemPurple
searchBar.searchTextField.font = UIFont(name: "American Typewriter", size: 18)

このように設定すると下記の様にテキストフィールドの背景色とテキストの色を変更できます。

Simulator Screen Shot - iPhone 11 Pro Max - 2019-11-23 at 12.05.02.png

UISearchToken

こちらは検索キーワードを一個のタグのように
取り扱うことができます。

例えば下記のようにsearchTextFieldプロパティへトークンを追加すると
下記の様に表示されます。

let phoneToken = UISearchToken(icon: UIImage(systemName: "phone"), text: "電話")
let messageToken = UISearchToken(icon: UIImage(systemName: "message"), text: "メッセージ")
searchBar.searchTextField.insertToken(phoneToken, at: 0)
searchBar.searchTextField.insertToken(messageToken, at: 0)
searchBar.searchTextField.tokenBackgroundColor = .systemBlue

Simulator Screen Shot - iPhone 11 Pro Max - 2019-11-23 at 11.41.42.png

※ トークンの色はsearchTextFieldのプロパティに設定しているので
個々のトークンで色を決めることはできないようです。
(あったら嬉しいかなとちょっと思ったりしました。)

UISearchTextFieldDelegate

searchTextField(_:itemProviderForCopying:)というメソッドを一つ持つProtocolです。

https://developer.apple.com/documentation/uikit/uisearchtextfielddelegate/3175446-searchtextfield

引数から見るとコピー&ペーストで使用するように思われます。
(WWDCでもコピー&ペーストと言っていました)

こちら全然情報見つからなかったのですが
検した結果を後ほど記載させていただきたいと思います。

実装

もう少し詳しく動作を確認するために
UISearchBarを使って
UITableViewに表示されるデータの絞り込みをします。

※ 動作の確認をするもので内容は適当ですので予めご了承ください。

事前準備

まず
下記のようなPlanというenumを用意します。
3つのcaseがあって
それぞれにitemsが存在します。

UISearchBarに入力されたキーワードの最初の2文字が一致したら
インスタンスを生成するようにinitを用意します。

enum Plan: String, CaseIterable {
    case business
    case travel
    case shopping

    init?(_ text: String) {
        if text.starts(with: "bu") {
            self = .business
        } else if text.starts(with: "tr") {
            self = .travel
        } else if text.starts(with: "sh") {
            self = .shopping
        } else {
            return nil
        }
    }

    var items: [String] {
        switch self {
        case .business:
            return ["会議", "商談", "プレゼン", "勉強会"]
        case .travel:
            return ["宿泊", "日帰り"]
        case .shopping:
            return ["スーパー", "デパート"]
        }
    }

    var iconName: String {
        switch self {
        case .business:
            return "bag"
        case .travel:
            return "airplane"
        case .shopping:
            return "cart"
        }
    }
}

次に
UISearchBarの設定です。

class ViewController: UIViewController {
    @IBOutlet weak private var tableView: UITableView!

    private var searchController = UISearchController()

    private var searchBar: UISearchBar {
        searchController.searchBar
    }

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

    private func setupSearchBar() {
        searchController.searchResultsUpdater = self
        searchController.searchBar.placeholder = "検索したいキーワード"

        // UISearchTextField
        searchBar.searchTextField.backgroundColor = .systemOrange
        searchBar.searchTextField.textColor = .systemPurple
        searchBar.searchTextField.font = UIFont(name: "American Typewriter", size: 18)
        searchBar.searchTextField.tokenBackgroundColor = .systemBlue
    }
}

ここは上記で見たように表示方法などの設定を行っています。

全体は最後に記載させていただきますので
ここからは要点だけ見ていきます。

トークンの作成

最初にトークンを作成する実装をしていきます。
下記のメソッドはUISearchBarに入力された時に
トークンを作成するメソッドです。

extension ViewController {

    private func setToken(from plan: Plan) {
        // 1
        let planToken = UISearchToken(icon: UIImage(systemName: plan.iconName), text: plan.rawValue)
        // 2
        planToken.representedObject = plan
        // 3
        let field = searchBar.searchTextField
        field.replaceTextualPortion(of: field.textualRange, with: planToken, at: field.tokens.count)
    }
}

まず、1でUISearchTokenのインスタンスを生成しています。

2ではUISearchTokenが公開しているrepresentedObjectプロパティに
それぞれのトークンを識別するための値を設定できます。

取得する際には下記のようにtokensというプロパティから探します。

private func extractSearchPlans() -> [Plan] {
    searchBar.searchTextField.tokens.compactMap { $0.representedObject as? Plan }
}

3で入力した文字列をトークンに置き換えています。

replaceTextualPortionですが
ソースコメントに下記のような説明が記載されています。

Removes any text contained in the specified range, 
inserts the provided token at the specified index, 
and selects the newly-inserted token. 
Does not replace any tokens within the provided range. 
If the range intersects the marked text range, the marked text is committed.

This method is essentially a convenience wrapper 
around the more fundamental `text`, `tokens`, and `selectedTextRange` properties, 
providing the selection behavior the user will expect.

@note
Because this method does not remove any tokens in the provided range, 
the caller can pass the fields selectedTextRange 
to convert the selected portion of the text 
into a token without first having to trim the range.

メソッドの引数で指定しているtextualRangeには
下記のようなソースコメントが記載されています。

The range that corresponds to the fields text, exclusive of any tokens.

@see -[<UITextInput> positionWithinRange:atCharacterOffset:]

トークン部分を除いたテキストの範囲が設定されているようです。

動作

このような設定をすると
下記のような動きが実現できました。

UISearchBar720.gif

コピー&ペースト

ここからはコピー&ペーストの実装をしていきます。

まずsearchTextFieldに下記の設定を追加します。

searchBar.searchTextField.allowsCopyingTokens = true

どういう実装が必要なのかがわからなかったので
allowsCopyingTokensのソースコードを見てみます。

Whether the user can copy tokens to the pasteboard or drag them out of the text field.
To support copying tokens, 
this property must be true and the delegate must provide an item provider 
for the tokens to be copied. 
UISearchTextField always enables the Copy command 
if any plain text is selected, 
even if the selection also includes tokens and this property is false. 
Defaults to true.

ここからわかることとして

the user can copy tokens to the pasteboard

UIPasteboardを使用する

ということと

this property must be true 
and the delegate must provide an item provider 
for the tokens to be copied

とあるのでdelegateの実装が必要になるようです。

UIPasteboardの説明は割愛させていただきます。
↓をご参照ください。
https://developer.apple.com/documentation/uikit/uipasteboard

UISearchTextFieldDelegateの実装

まずUISearchTextFieldDelegateの実装をしていきます。

extension ViewController: UISearchTextFieldDelegate {
    func searchTextField(_ searchTextField: UISearchTextField, itemProviderForCopying token: UISearchToken) -> NSItemProvider {
        guard let plan = token.representedObject as? Plan else {
            return NSItemProvider()
        }
        return NSItemProvider(object: PastePlan(plan))
    }
}

このメソッドはトークンのCopyをタップした際に呼ばれます。

ここではUIPasteboardに設定するNSItemProviderを用意しています。

NSItemProviderの説明は割愛させていただきます。
↓をご参照ください。
https://developer.apple.com/documentation/foundation/nsitemprovider

取得したい情報は
上記のsetTokenメソッドの中で設定した
representedObjectから取得しています。

delegateの設定も忘れないように行います。

searchBar.searchTextField.delegate = self

UIPasteboardへ値の書き込みと読み込み

次にUIPasteboardへ値の書き込みと読み込みをするための実装をしていきます。

そのためにUITextPasteDelegateを実装します。
https://developer.apple.com/documentation/uikit/uitextpastedelegate

使用するメソッドはUITextPasteItemが取得できる

textPasteConfigurationSupporting(
    _ textPasteConfigurationSupporting: UITextPasteConfigurationSupporting, 
    transform item: UITextPasteItem)

です。

extension ViewController: UITextPasteDelegate {
    func textPasteConfigurationSupporting(_ textPasteConfigurationSupporting: UITextPasteConfigurationSupporting, transform item: UITextPasteItem) {
        guard let item = item as? UISearchTextFieldPasteItem else {
            return
        }

        item.itemProvider.loadObject(ofClass: PastePlan.self) {
            (pastePlan, error) in
            guard let plan = (pastePlan as? PastePlan)?.plan else {
                return
            }
            let token = UISearchToken(icon: UIImage(systemName: plan.iconName), text: plan.rawValue)
            item.setSearchTokenResult(token)
        }
    }
}

UISearchTextFieldPasteItemはProtocolで
setSearchTokenResult(_ token: UISearchToken)メソッドがあり
ここでトークンをUIPasteboardに設定することができます。

UISearchTextFieldPasteItem
https://developer.apple.com/documentation/uikit/uisearchtextfieldpasteitem

上記のコードで出てきているPastePlan
NSItemProviderに値を設定するために用意したクラスです。
(コードは最後に記載しています。)
内部でPlanを保持するようにしています。

またJSONとして値を取得するために
Codableにも適合するようにしています。

delegateは
searchTextFieldが適合する
UITextPasteConfigurationSupporting
pasteDelegateへの設定が必要になります。

searchBar.searchTextField.pasteDelegate = self

https://developer.apple.com/documentation/uikit/uitextpasteconfigurationsupporting/2887494-pastedelegate

UIPasteboardからの値の取得

最後にUIPasteboardから値を取得します。

private func extractPlanFromPasteboard() -> Plan? {
    guard let data = UIPasteboard.general.data(forPasteboardType: (kUTTypeJSON as String)) else {
        return nil
    }
    UIPasteboard.general.items = []
    return try? JSONDecoder().decode(PastePlan.self, from: data).plan
}


最終的なトークンの設定や表示するデータのフィルターは
UISearchResultsUpdating
updateSearchResults(for searchController: UISearchController)
の中で行っています。

動作

このような実装をすることで
下記のような動作が実現できました。

画面収録-2019-11-24-8.20.58.gif

今回使用したコード

下記に記載もしていますが
gistにもアップロードしました。

https://gist.github.com/stzn/4e49e0afe3d4208a824e8eed53f24c61

全体のコード
import UIKit
import MobileCoreServices

class PastePlan: NSObject, NSItemProviderReading, NSItemProviderWriting, Codable {
    let plan: Plan
    init(_ plan: Plan) {
        self.plan = plan
    }
    static var readableTypeIdentifiersForItemProvider: [String] = [(kUTTypeJSON) as String]
    static var writableTypeIdentifiersForItemProvider: [String] = [(kUTTypeJSON) as String]

    static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Self {
        try JSONDecoder().decode(self, from: data)
    }

    func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {
        let progress = Progress(totalUnitCount: 100)
        do {
            let data = try JSONEncoder().encode(self)
            progress.completedUnitCount = 100
            completionHandler(data, nil)
       } catch {
           completionHandler(nil, error)
       }

       return progress
    }
}

enum Plan: String, CaseIterable, Equatable, Codable {
    case business
    case travel
    case shopping

    init?(_ text: String) {
        if text.starts(with: "bu") {
            self = .business
        } else if text.starts(with: "tr") {
            self = .travel
        } else if text.starts(with: "sh") {
            self = .shopping
        } else {
            return nil
        }
    }

    var iconName: String {
        switch self {
        case .business:
            return "bag"
        case .travel:
            return "airplane"
        case .shopping:
            return "cart"
        }
    }

    var items: [String] {
        switch self {
        case .business:
            return ["会議", "商談", "プレゼン", "勉強会"]
        case .travel:
            return ["宿泊", "日帰り"]
        case .shopping:
            return ["スーパー", "デパート"]
        }
    }
}

class ViewController: UIViewController {
    @IBOutlet weak private var tableView: UITableView!

    private var searchController = UISearchController()

    private var filteredItems: [[String]] = []

    private var searchBar: UISearchBar {
        searchController.searchBar
    }

    private var isSearchBarEmpty: Bool {
        if !searchBar.searchTextField.tokens.isEmpty {
            return false
        }
        return searchBar.text?.isEmpty ?? true
    }

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

    private func setupSearchBar() {
        searchController.searchResultsUpdater = self
        searchController.obscuresBackgroundDuringPresentation = false
        searchBar.placeholder = "検索キーワード"
        definesPresentationContext = true

        // UISearchTextField
        searchBar.searchTextField.backgroundColor = .systemOrange
        searchBar.searchTextField.textColor = .systemPurple
        searchBar.searchTextField.font = UIFont(name: "American Typewriter", size: 18)
        searchBar.searchTextField.tokenBackgroundColor = .systemBlue

        // Delete
        searchBar.searchTextField.allowsDeletingTokens = true

        // Copy & Paste
        searchBar.searchTextField.allowsCopyingTokens = true
        searchBar.searchTextField.delegate = self
        searchBar.searchTextField.pasteDelegate = self
    }

    private func setupTableView() {
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        tableView.dataSource = self
        tableView.tableHeaderView = searchController.searchBar
    }
}

extension ViewController: UISearchTextFieldDelegate {
    func searchTextField(_ searchTextField: UISearchTextField, itemProviderForCopying token: UISearchToken) -> NSItemProvider {
        guard let plan = token.representedObject as? Plan else {
            return NSItemProvider()
        }
        return NSItemProvider(object: PastePlan(plan))
    }
}

extension ViewController: UITextPasteDelegate {
    func textPasteConfigurationSupporting(_ textPasteConfigurationSupporting: UITextPasteConfigurationSupporting, transform item: UITextPasteItem) {
        guard let item = item as? UISearchTextFieldPasteItem else {
            return
        }

        item.itemProvider.loadObject(ofClass: PastePlan.self) {
            (pastePlan, error) in
            guard let plan = (pastePlan as? PastePlan)?.plan else {
                return
            }
            let token = UISearchToken(icon: UIImage(systemName: plan.iconName), text: plan.rawValue)
            item.setSearchTokenResult(token)
        }
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if isSearchBarEmpty {
            return Plan.allCases[section].items.count
        }
        if !filteredItems.isEmpty {
            return filteredItems[section].count
        }
        return 0
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        var item: String? = nil
        if isSearchBarEmpty {
            item = Plan.allCases[indexPath.section].items[indexPath.row]
        } else if !filteredItems.isEmpty {
            item = filteredItems[indexPath.section][indexPath.row]
        }
        cell.textLabel?.text = item
        return cell
    }
}

extension ViewController: UITableViewDelegate {
    func numberOfSections(in tableView: UITableView) -> Int {
        if isSearchBarEmpty {
            return Plan.allCases.count
        }

        if !filteredItems.isEmpty {
            return filteredItems.count
        }

        return 0
    }

    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        Plan.allCases[section].rawValue
    }
}

extension ViewController: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        var text = searchBar.text!.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
        guard !isSearchBarEmpty else {
            return
        }

        var searchPlans: [Plan] = extractSearchPlans()
        if let plan = Plan(text) {
            setToken(from: plan)
            searchPlans.append(plan)
            text = ""
        } else if let plan = extractPlanFromPasteboard() {
            searchPlans.append(plan)
            text = ""
        }
        updateUI(plans: searchPlans, text: text)
    }

    private func extractPlanFromPasteboard() -> Plan? {
        guard let data = UIPasteboard.general.data(forPasteboardType: (kUTTypeJSON as String)) else {
            return nil
        }
        UIPasteboard.general.items = []
        return try? JSONDecoder().decode(PastePlan.self, from: data).plan
    }

    private func extractSearchPlans() -> [Plan] {
        searchBar.searchTextField.tokens.compactMap { $0.representedObject as? Plan }
    }

    private func setToken(from plan: Plan) {
        let planToken = UISearchToken(icon: UIImage(systemName: plan.iconName), text: plan.rawValue)
        planToken.representedObject = plan
        let field = searchBar.searchTextField
        field.replaceTextualPortion(of: field.textualRange, with: planToken, at: field.tokens.count)
    }

    private func updateUI(plans: [Plan], text: String) {
        filteredItems = []
        if !plans.isEmpty {
            filteredItems = Plan.allCases.filter { plans.contains($0) }
                .map {
                    $0.items.filter { $0.starts(with: text) }
            }
        } else {
            filteredItems = Plan.allCases.map {
                    $0.items.filter { $0.starts(with: text) } }
        }
        tableView.reloadData()
    }
}

まとめ

iOS13のUISearchBarの新機能について見ていきました。

特にUISearchTokenに関しては
検索キーワードを一つのまとまりとして扱ったりすることができ
使い道がたくさんあるのではないかと思います。

ただ現在情報が見つからないため
正式なサンプルやドキュメントなどが
早く出てくると嬉しいですね😃

もし間違いなどございましたら教えていただけますとうれしいです🙇🏻‍♂️

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

【Swift】OpenAPIGeneratorにPRを出してみる

いくつかのiOSアプリ案件でOpenAPIGeneratorを使ってswift4のAPIクライアントを自動生成しているのですが、とても快適です。

利用状況はこんな感じ

  • OpenAPIClientで生成したファイルは、〇〇(プロジェクト名)APIClientとしてプロジェクト本体のレポジトリとは別のレポジトリとして管理
  • SwiftPackageManagerに対応させて、SPM経由でプロジェクトからimportできるように
  • update.sh というシェルを○○APIClientディレクトリのルートにおいて、実行してタグ更新するだけでAPIの更新が出来るようにし、人依存を撤廃
  • いくつか都合が悪いところは、static var の部分を修正して、カスタマイズして対応。(BuilderFactoryを丸ごと切り替えられるようになっているので、最終的に全部カスタマイズ可能な作りになっていてとても便利です)

別レポジトリに切り出したのは、APIClientはプロジェクトの内部構造に依存せず独立運用可能なので、切り離して運用する事でプロジェクトレポジトリの複雑度をむやみに上げないためというのが1つと、swift4のgeneratorがAlamofireの古いバージョンを利用しているのですが、プロジェクトディレクトリからどのバージョンのライブラリを使っているとかを一切意識せずに使えるようにしたかったからです。余計な依存性を含めたくありませんでした。

ただ、使っていて2点だけ困るところがあったので修正リクエストを送ってみようと思います。内容は以下です。

  1. レスポンスを受け取るQueueが選択できず、常にメインスレッドが使われるので切り替えられるようにしたい。
  2. RequestBuilderのときはNonDecodableBuilderになって欲しい。

ただ、ちょっと敷居が高くて手を出しにくいOSSに感じるので再現できるように自分の体験した手順を公開しながら進めて行こうと思います。

今回は1.のスレッドの件を題材にします。

それでは進めていきます。

1. Fork

プロジェクトをforkします。

2. Swift4のMustacheを管理しているディレクトリを発見する

まず、これがなかなか見つかりませんでした。

「GitHub OpenAPIGenerator Mustache」とグーグル検索して、色々ディレクトリを掘って探しているとmodules/openapi-generator/src/main/resourcesの下に見つけられました

3. レスポンスを受け取るQueueを選択できるようにしてみる

コールバックをmainキューに指定できるのはネットワークのライブラリではよくある事です。ただ、利用側が任意に切り替えられないのが不便だと思った点でした。そこでmasterブランチからresponse-queueというブランチを切って、提案を出してみようと思います。

カスタマイズ可能な仕組みの open class {{projectName}}API に対して、

public static var queue: DispatchQueue = .main

という記述を加え、通信を行う AlamofireImplementations.mustache の中で、Alamofireに対してqueueを渡して通信するようにしてみます。

いくつか変更を加えてみました。

https://github.com/syatyo/openapi-generator/commit/41428e08a6763c76df296d12c1235fe20d5ccfa1

https://github.com/syatyo/openapi-generator/commit/dac4f48c480dabcc07a2d1945feb31b24997f859

4. 自動生成を試してみる

みた感じうまくいってそう。

java -jar modules/openapi-generator-cli/target/openapi-generator-cli.jar generate -i https://raw.githubusercontent.com/openapitools/openapi-generator/master/modules/openapi-generator/src/test/resources/2_0/petstore.yaml -g swift4 -o ~/Desktop/TestAPIClient

5. PRを出そうとする...前の準備

PRを出す前に、プロジェクト規約がしっかり決まっているようなので、こちらのファイルを良く読んでみます。
https://github.com/openapitools/openapi-generator/blob/master/CONTRIBUTING.md

Before Submitting PRを読むと、「PR出す前にissue探してなかった場合はまずissue立ててくれ」と書いてあるので、issueを立ててみました。

割と支離滅裂な英語かもしれませんが、とりあえず書きたい事は書きました。
https://github.com/OpenAPITools/openapi-generator/issues/4590

次にテストの章を実行していきます。

PetStoreのサンプルをアップデート

こちらを実行すれば良いっぽいです。

./bin/swift4-all.sh

テスト実行

テスト実行用の便利なshellを発見したので、これを使っていきます。
samples/client/test/swift4/swift4_test_all.sh

自分の場合Xcode11を使っているからかテストかうまく終わらず、理由もエラーを読んでも特定しにくいものだったので、samples/client/petstore/swift4/default/SwaggerClientTests/SwaggerClient.xcworkspaceを開いて、Xcodeから直接テストを実行しました。

特にテストケースの追加はしていないのでこのままコミットを行います。

6. 準備ができたのでいざPR

一通りガイドラインの準備事項が終わったので、PRを出していきます。

PRを出す時のチェックリストがあるのでいくつか雑に翻訳してながら見ていきます。

  • ガイドライン読んで従ってね
  • プロジェクトのビルドしてね
  • サンプルの再出力してね
  • 正しいブランチにPR向けてね
  • コミッターの一覧に入ってる言語ごとのレビュワーを全部コピーしてメンションしてね

ざっくりこんな感じのはず。

全部確認したのでPRを出します。

完了!

https://github.com/OpenAPITools/openapi-generator/pull/4591

無事、PRを出すところまでは成功しました。ガイドラインが厳しめで結構大変でしたが、ほっと一息。

なんかビルドエラー出てるけど明日考えよう。何回か修正するとしても、マージまでいけると嬉しいなー。

それではお付き合いありがとうございました!みなさんがPR出す時のヘルプになれば良いと思います!

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

#16 iOS13以降の画面遷移で前の画面に戻れてしまう問題の解決方法1例

はじめに

個人のメモ程度の出来なのであまり参考にしないで下さい.

環境

Xcode:11.2.1
Swift:5.1.2
2019/11

part1

画面の遷移先のView Controllerを選択している状態で,Attributes inspectorPresentationFull Screenに変更する.

スクリーンショット 2019-11-24 午前0.39.36.png

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

#15 TabBarItemをカスタマイズする1例

はじめに

個人のメモ程度の出来なのであまり参考にしないで下さい.

環境

Xcode:11.2.1
Swift:5.1.2
2019/11

part1

Tab Bar Itemを選択する.

スクリーンショット 2019-11-24 午前0.07.24.png

part2

Attributes inspectorTitleを任意の文字列にし,Imageを任意のものを選ぶ.

スクリーンショット 2019-11-24 午前0.13.36.png

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