20191124のSwiftに関する記事は12件です。

【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で続きを読む

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で続きを読む

再利用できるViewを作りたい

完成形

スクリーンショット 2019-11-24 16.32.14.png

Viewの構成

今回はECサービスなどで見かけるUIStackViewで積み上げていく商品詳細画面を作ってみました。構成はUICollectionViewの中にUICollectionViewを入れるのではなく、UIStackViewの中にUIViewを入れて、その中にUICollectionViewを入れる実装になります。
スクリーンショット 2019-11-24 17.00.18.png

実装

※クラス名などは悩み中なので、適当な名前を付けています。
※ビューのレイアウト部分は所々端折ります。

UICollectionViewCellの拡張

cellの登録でUINibのnameで使用します

extension UICollectionViewCell {
    static var className: String {
        return String(describing: self)
    }
}

Controllerの定義

protocol ItemsSectionController {
    associatedtype Item
    associatedtype Cell: UICollectionViewCell
    var data: BehaviorRelay<[Item]> { get }
    var headerText: String { get }
    var cellIdentifier: String { get }
    func config(cell: Cell, item: Item)
}

ここにheaderTextcellIdentifierを入れていいのか不明

共通するビューの実装

今回はCellのサイズ計算をUICollectionViewCellのサブクラスで
preferredLayoutAttributesFitting(UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes
をoverrideさせてサイズを決定しているので、
layout.itemSize = UICollectionViewFlowLayout.automaticSize
を設定しているのと
estimatedItemSizeには想定しているcellの高さを入れています。

class ItemsSectionView<Controller: ItemsSectionController>: UIView {
    struct CellIdentity {
        let identifier: String
        let nibname: String
        let cellType: UICollectionViewCell.Type
        init(identifier: String, type: UICollectionViewCell.Type) {
            self.identifier = identifier
            cellType = type
            nibname = type.className
        }
    }  

    private lazy headerLabel: UILabel = UILabel()
    private lazy var horizontalCollectionView: UICollectionView = {
        let flowLayout: UICollectionViewFlowLayout = {
            let layout = UICollectionViewFlowLayout()
            layout.scrollDirection = .horizontal
            layout.itemSize = UICollectionViewFlowLayout.automaticSize
            layout.estimatedItemSize = CGSize(width: 210, height: 210)
            return layout
        }()
        let view = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    private let controller: Controller
    private let disposeBag = DisposeBag()

    init(controller: Controller, frame: CGRect = .zero) {
        self.controller = controller
        super.init(frame: frame)
        headerLabel.text = controller.headerText
        horizontalCollectionView.register(UINib(nibName: Controller.Cell.className, bundle: nil), forCellWithReuseIdentifier: controller.cellIdentifier)
        controller.data.asDriver(onErrorJustReturn: [])
            .drive(horizontalCollectionView.rx.items(cellIdentifier: controller.cellIdentifier, cellType: Controller.Cell.self)) { _, item, cell in
                controller.config(cell: cell, item: item)
            }
            .disposed(by: disposeBag)
        viewConfigure()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func viewConfigure() {
        addSubview(headerLabel)
        addSubview(horizontalCollectionView)
        //レイアウト処理
    }
}

Controllerの実装

実際に先ほど定義したControllerに準拠したクラスを定義します。
ItemはCollectionViewに表示するためのものなので、エンティティレイヤーで定義したものを入れるよりは表示する必要最低限のコンポーネントをControllerに構造体で定義してあげるのが自然なのかなって思います。

class NoteItemsSectionController: ItemsSectionController {
    struct Note {
        let title: String
        let editor: String
        let image: UIImage
        let publishedDate: Date
    }

    typealias Item = Note
    typealias Cell = NoteItemCollectionViewCell

    var headerText: String {
        return "最近投稿したコラム"
    }
    let data: BehaviorRelay<[NoteItemsSectionController.Column]> = .init(value: [])

    let cellIdentifier: String = "note"

    func config(cell: NoteItemsCollectionViewCell, item: NoteItemsSectionController.Note) {
        cell.imageView.image = item.image
        cell.titleLabel.text = item.title
        cell.editorLabel.text = item.editor
        cell.publishedDateLabel.text = publishedDate.toString() //独自拡張
    }
}

ViewModelの実装

class ShopAnalyticsViewModel {
    let productSectionController: ItemsSectionController = ProductItemsSectionController()
    let noteSectionController: ItemsSectionController = NoteItemsSectionController()

    private let repository = Repository()
    private let disposeBag = DisposeBag()

    init(id: String) {
        repository.producs(id: id, limit: 10)
            .catchErrorJustReturn([])
            .asObservable()
            .bind(to: productSectionController.data)
            .disposed(by: disposeBag)
        repository.notes(id: id, limit: 3)
            .catchErrorJustReturn([])
            .asObservable()
            .bind(to:noteSectionController.data)
            .disposed(by: disposeBag)
    }
}

ViewControllerの実装

class ItemDetailsViewController: UIViewController {
    @IBOutlet weak var stackView: UIStackView!

    private let viewModel: ViewModel

    init(id: String) {
        viewModel = ViewModel(itemID: id)
        super.init(nibName: String(describing: type(of: self)), bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func loadView() {
        super.loadView()
        sectionView.addArrangedSubview({
            let view = generateSectionView(controller: viewModel.productSectionController)
            view.heightAnchor.constraint(equalToConstant: 250).isActive = true
            return view
        }())
        sectionView.addArrangedSubview({
            let view = generateSectionView(controller: viewModel.noteSectionController)
            view.heightAnchor.constraint(equalToConstant: 300).isActive = true
            return view
        }())
    }
    private func generateSectionView<Controller: ItemsSectionController>(controller: Controller) -> UIView {
        let view = ItemsSectionView(controller: controller)
        view.translatesAutoresizingMaskIntoConstraints = false
    }
}

まとめ

フワッとした考えからとりあえず手を動かしてみたので所々無駄がある感じですが、一応動くところまで持っていけました。associatedtypeがあるprotocolを使おうとするとどこかで汚くなってしまい、納得のいく実装にはなりませんでした。
StackViewのサブビュー生成はUIViewControllerではなく、ViewModelがやってもいいのではとかいろいろ悩んでいます。

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

Mint + SwiftFormatで躓いた話

MintはXcodeGenやSwiftFormat、SwiftLintなど便利なコマンドラインツールをまとめてくれるやつです。
https://github.com/yonaskolb/Mint

Mintのインストール

Homebrewでいけます
brew install mint

Mintfileに必要なツールを記述

yonaskolb/xcodegen@2.10.1
nicklockwood/SwiftFormat@0.40.14

ツールのインストール

mint bootstrap

ツールの実行

BuildPhasesに新規でRunScriptを追加
bootstrapで入れたので実行にはmint runを使います

mint run swiftformat . 

実際にBuildしてみるとCommand PhaseScriptExecution failed with a nonzero exit codeが出てしまった、、。

エラーの原因

https://github.com/yonaskolb/Mint/issues/145
詳しく読んではいないのですが、どうやら
mint run <repository> <binary> [args]
こう言う風に書くのが正しいっぽいです。

mint run swiftformat swiftformat .
or
mint run nicklockwood/SwiftFormat swiftformat .
と書くと正しく動きます。

リポジトリを指すので混乱を避けるために
mint run nicklockwood/SwiftFormat swiftformat .
と書いてあげる方が良さそうです。

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

Mint + SwiftFormatでつまづいた話

MintはXcodeGenやSwiftFormat、SwiftLintなど便利なコマンドラインツールをまとめてくれるやつです。
https://github.com/yonaskolb/Mint

Mintのインストール

Homebrewでいけます
brew install mint

Mintfileに必要なツールを記述

yonaskolb/xcodegen@2.10.1
nicklockwood/SwiftFormat@0.40.14

ツールのインストール

mint bootstrap

ツールの実行

BuildPhasesに新規でRunScriptを追加
bootstrapで入れたので実行にはmint runを使います

mint run swiftformat . 

実際にBuildしてみるとCommand PhaseScriptExecution failed with a nonzero exit codeが出てしまった、、。

エラーの原因

https://github.com/yonaskolb/Mint/issues/145
詳しく読んではいないのですが、どうやら
mint run <repository> <binary> [args]
こう言う風に書くのが正しいっぽいです。

mint run swiftformat swiftformat .
or
mint run nicklockwood/SwiftFormat swiftformat .
と書くと正しく動きます。

リポジトリを指すので混乱を避けるために
mint run nicklockwood/SwiftFormat swiftformat .
と書いてあげる方が良さそうです。

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

【Fire base+swift】学習�記録(5)xcodeでアプリ申請→公開まで

Firebaseを勉強し始めてから、2週間ほど経ちました。
今までで作成したアプリは全て、サーバーなどよくわからなかったので一人で完結するものでした。
アプリを作る上でいろいろな人が繋がれる物が作りたくて、Firebaseを勉強し始めました。

先日簡単ですが、アプリができたので早速アプリ申請に通しました。
わーい!

アプリ申請は2回目ですが、やり方忘れており備忘録としてこちらにまとめておきます。
前提として、自分はすでにDeveloper登録しています。

アプリ公開まで

①BundleIDの登録

apple developer>Account>Certificates, Identifiers & Profiles>Identifiers>「+ボタン」からBundleIDを登録
https://developer.apple.com/
スクリーンショット 2019-11-24 13.51.49.png

②MyAPP作成

app store conectのMyAPPの「+ボタン」から新しいアプリを作成
スクリーンショット 2019-11-24 13.52.06.png

③Xcodeからアプリをアーカイブ

ビルド先を「Generic iOS Device」にして、Archiveしましょう。
これで、app store conectのMyAPPから提出可能な状態になります。
スクリーンショット 2019-11-24 13.53.33.png

少しハマったポイント

①アイコン画像は透明部分があってはいけない。(pngで作成して、透明部分があると拒否される)
②Appstore用のスクリーンショットは全部用意しないといけない
③MyAPPでカテゴリーやプライスも設定する

提出ができれば、自動的にAppStoreに公開されます。
審査が一発で通ることを願って待ちます!

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

#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で続きを読む

複雑なUIScrollViewダンジョンをXcode11の新機能で攻略する

はじめに

久しぶりにUIScreenViewダンジョンに入ったら攻略できなかったので攻略法の備忘録。

背景

たくさんの人が利用しているだろうSnapKitをうちでも使用していて、ScrollViewにAutoLayoutかけようと思ったら難しかったです。

ポップアップのようなビューを表示していたんですが、SE端末だけ下のタブバー上のナビバーの間の表示領域を飛び出してしまうので、上限がきたらスクロールに切り替えるという実装をすることになりました。

満たしたい条件

scrolll.png

  • SnapKit を使用(以下ちょいちょいこれ使用の前提で話します)
  • ボタンやらラベルやら詰め込まれているViewを使用(スクロールさせたいContentView)
  • Viewの指定
    • width最大値指定あり(iPad様の限界値を作るため)
    • height最大値指定あり(限界まできたらマージン分で止める)
    • ContentViewのheightは内部パーツで決まる可変
    • 基本中央表示
    • height限界値に到達した場合スクロールさせる、かつContentViewのtopはScrollViewのtopに合わせる

色々条件がありますがXcode11からScrollViewの仕様が少し変わったこともあり、基本形と条件指定形をまとめやり方を定着させたい。参考になる方がいれば。

ScrollViewに制約をかけるときの基本

Xcode11からScrollViewは

  • FrameLayoutGuide
  • ContentLayoutGuide

というのを持つようです。

scrollguide.png

登場人物

  • ScrollView = ScrollView自体、ContentViewを覗くフレームに直結
  • FrameLayoutGuide = 上記ScrollViewフレームのガイド
  • ContentLayoutGuide = ContentViewを合わせるガイド
  • ContentView = スクロールさせたいView

基本的にはこの子たちに制約かけていく形になります。
ざっと、
ScrollView FrameLayoutGuideで窓?
ContentView ContentLayoutGuideで中身?を窓?にどう表示したいか
を決める感じかと思います。

ContentView.frame.heightなどが可変で設定したScrollViewの高さ未満の場合、スクロールされません。

いきなり最初にあげたミッション達成条件を満たすのは難易度が高いので、順番に下の階層からダンジョンを上がっていく。

ScrollViewに対するAutolayoutのかけ方

SnapKitの使用前提です。

1階層

ミッション達成条件

⭐️ 単一方向スクロール(縦or横スクロール)
⭐️ ScrollViewは全画面

scrollView.snp.makeConstraints { make in
    make.edges.equalToSuperView() //全画面に窓固定
}

contentView.snp.makeConstraints { make in
    make.width.equalTo(scrollView.frameLayoutGuide) //縦スクロールのみしたい場合はwidthだけframeLayoutGuideに合わせる
    make.edges.equalTo(scrollView.contentLayoutGuide) //contentLayoutGuideに合わせる
}

でミッションコンプリートです。簡単です。
ちなみに横スクロールしたい場合は
make.height.equalTo(scrollView.contentLayoutGuide)で高さを固定すればおkです。
両方固定するとスクロールしないので注意です。

備考

低難易度のうちにどうなっているか整理したい、うやむやの中先に進むと崩壊します。

make.edges.equalToSuperView()は窓?を作るので、作りたい窓?の大きさを設定します。
全画面にしたいとか、superViewの下半分にScrollを作りたいとか、ここでsuperView基準で作ります。

make.width.equalTo(scrollView.frameLayoutGuide)は、
contentView(表示させたい中身?)の横幅をscrollView(frameLayoutGuide)に収めたいので、
中身?.width = 窓?.widthとします。
縦幅(height)は設定していないので垂れ流し = frameから外れる = 外れた分がスクロール対象領域 という認識です。

make.edges.equalTo(scrollView.contentLayoutGuide)は、個人的には立ち位置の理解が少し難しかったのですが、中身?をScrollViewに対してどう表示させたいかを担う部分ということで今は落ち着いています。

という部分を頭に入れた上で階層を上がっていく。

2階層

ミッション達成条件

⭐️ 単一方向スクロール(縦or横スクロール)
⭐️ ScrollViewの大きさは指定条件でレイアウト
⭐️ ScrollViewはsuperViewの下半分、左右下に指定のマージン

scrollView.snp.makeConstraints { make in
    make.left.right.bottom.equalToSuperView().inset(10) //マージン部分の基準作成
    make.top.equalTo(self.frame.height / 2) //scrollViewのheightをsuperViewの下半分の高さに指定
}

contentView.snp.makeConstraints { make in
    make.width.equalTo(scrollView.frameLayoutGuide) //縦スクロール設定
    make.edges.equalTo(scrollView.contentLayoutGuide) //contentLayoutGuideに合わせる
}

基準はそれぞれですが、scrollViewのtopをsuperViewの高さ/2の数値にイコールとすることでsuperViewの下半分の領域をScrollViewとして確保します。

この様に窓?自体のレイアウト指定がある場合はScrollViewに制約をかけてレイアウトを決めます。

次から少し?となり始める、というか、Autolayoutの正しい知識がないと討伐が大変になる。

3階層

ミッション達成条件

⭐️ 単一方向スクロール(縦or横スクロール)
⭐️ ScrollViewの大きさは指定条件でレイアウト
⭐️ ScrollViewは左右に指定のマージン
⭐️ ContentViewの高さ表示限界以内なら中央表示(超えたらスクロール)

補足しますが、この条件が意図しているところは

  • 上下は決まったマージンをとらず、遊ばせる(画面サイズまたはsuperViewによって可変)

ということなので、superViewに収まる範囲ならスクロールさせる必要はありません。
ですが例えばsuperViewの高さが小さい時など、限界突破してcontentViewの高さがsuperViewの縦幅をぶち抜いてしまった時にはスクロールを適用して差し上げます。

scrollView.snp.makeConstraints { make in
    make.left.right.equalToSuperView().inset(10) //左右は固定マージン
    make.height.lessThanOrEqualToSuperview() //scrollView高さ最大値はsuperview
    make.center.equalToSuperview() //scrollViewを中央配置
}

contentView.snp.makeConstraints { make in
    make.width.equalTo(scrollView.frameLayoutGuide) //縦スクロール設定
    make.left.right.bottom.equalTo(scrollView.contentLayoutGuide) //contentLayoutGuideに合わせる
    make.top.bottom.greaterThanOrEqualTo(scrollView.contentLayoutGuide) //条件で上下をcontentLayoutGuideに合わせる
    make.center.lessThanOrEqualToSuperview().priority(.required - 1) //条件でscrollviewを中央配置
}
scrollView

make.left.right.equalToSuperView().inset(10)
make.center.equalToSuperview()
窓?の左右マージンを固定で設置、中央にくる様に設定。

make.height.lessThanOrEqualToSuperview()
窓?の縦幅最大値をsuperViewにして限界突破しないようにする。

contentView

make.width.equalTo(scrollView.frameLayoutGuide)
お馴染み縦スクロール設定。

限界前は中央表示、限界きたらスクロールという設定は以下でバランスとって設定しないといけない。
ただ中央表示するだけではなく、限界にきたら中央表示ではなくcontentViewのtopは上揃えというふうに
表示条件を分けなければいけない。

make.left.right.bottom.equalTo(scrollView.contentLayoutGuide)
top以外の三方向はcontentLayoutGuideに揃える。topは条件で分けるので指定しない。

make.top.bottom.greaterThanOrEqualTo(scrollView.contentLayoutGuide)
限界にきたらtop bottomをcontentLayoutGuideに合わせる。

make.center.lessThanOrEqualToSuperview().priority(.required - 1)
中央表示はcontentViewsuperViewに到達するまでに設定。優先度は下げておく。
(この辺自信がないので指摘頂けると嬉しい)

4階層

ミッション達成条件

⭐️ 単一方向スクロール(縦or横スクロール)
⭐️ ScrollViewの大きさは指定条件でレイアウト
⭐️ ScrollViewは左右に指定のマージン
⭐️ ContentViewの高さ表示限界以内なら中央表示(超えたらスクロール)
⭐️ ScrollViewの高さ表示限界に指定のマージンをいれる

ラストダンジョンは最初にやりたかった条件達成でコンプリート。
3階層が終わればここはマージン指定するだけなので難しくない。

scrollView.snp.makeConstraints { make in
    make.left.right.equalToSuperView().inset(10) //左右は固定マージン
    make.height.lessThanOrEqualToSuperview().inset(5) //インセットかけるだけ
    make.center.equalToSuperview() //scrollViewを中央配置
}

contentView.snp.makeConstraints { make in
    make.width.equalTo(scrollView.frameLayoutGuide) //縦スクロール設定
    make.left.right.bottom.equalTo(scrollView.contentLayoutGuide) //contentLayoutGuideに合わせる
    make.top.bottom.greaterThanOrEqualTo(scrollView.contentLayoutGuide) //条件で上下をcontentLayoutGuideに合わせる
    make.center.lessThanOrEqualToSuperview().priority(.required - 1) //条件でscrollviewを中央配置
}

make.height.lessThanOrEqualToSuperview().inset(5)
scrollViewの高さ最大値はsuperviewに設定していたのでインセットをかける。

これで目標条件達成できました。

上記のように、端末サイズ指定や条件分岐を入れなくても
lessThanOrEqualTogreaterThanOrEqualTopriorityを使用することで調整できる。
という認識。

終わりに

Scrollダンジョン難しい。
だがFrameLayoutGuide ContentLayoutGuideというガイドがあるのでレイアウトを作りやすくなりました。
あとSnapKitとAutolayoutの勉強になりました。

自信ない部分もあるので間違っていたら申し訳ない、指摘いただきたいです。

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