- 投稿日:2019-11-24T22:20:08+09:00
【Swift/iOS】CoreLocationで緯度・経度を取得して位置情報を表示する
概要
CoreLocationで緯度・経度を取得して位置情報を表示する方法を説明します。
確認バージョン
Xcode 11.2.1
Swift 5.1.2
iOS 13.2.3CoreLocation
ライブラリ
TARGET -> Build Phase -> Link Binary With Libraries
CoreLocation.frameworkを追加
Info.plist
Privacy - Location When In Use Usage Description
位置情報許可のアラート画面に表示される説明を追加します。実装
- 情報表示用のUILabelを追加
- 位置情報を利用許可状態を判定
- 位置情報の更新開始
- CLLocationデータを逆ジオコーディングして位置情報を取得
- 位置情報が更新されると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を使って”ジオコーディング・逆ジオコーディング”をやってみる開発アプリ
- 投稿日:2019-11-24T18:49:06+09:00
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 = self
をfloatingPanelController.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)
- 投稿日:2019-11-24T18:00:30+09:00
再利用できるViewを作りたい
完成形
Viewの構成
今回はECサービスなどで見かけるUIStackViewで積み上げていく商品詳細画面を作ってみました。構成はUICollectionViewの中にUICollectionViewを入れるのではなく、UIStackViewの中にUIViewを入れて、その中にUICollectionViewを入れる実装になります。
実装
※クラス名などは悩み中なので、適当な名前を付けています。
※ビューのレイアウト部分は所々端折ります。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) }ここに
headerText
とcellIdentifier
を入れていいのか不明共通するビューの実装
今回は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がやってもいいのではとかいろいろ悩んでいます。
- 投稿日:2019-11-24T16:39:30+09:00
[はじめてのiOSアプリ]xcodeで地図アプリを作成(その1)
はじめに
iOSアプリを作ってみたいけど
何から始めて良いのかわからないとりあえず、
「やってみました」記事を参考に
地図アプリを真似てみようと思うという記事の1回目です。
今回は、プロジェクトを作成します。
+αの目標
世の中に「やってみました」記事は、たくさんある。
多くの場合、同じことをすれば同じ結果を得ることができる。しかし「なぜそのようなことをするのか?」を
解決できないから自分が成長しない。『人が書いた「やってみました」記事を参考に
できるだけ「なぜ?」を解決しながらやってみよう』(汗)機能リスト
基本機能として、このような機能があればいいかな?
- 地図を表示
- GPS(corelocation)で現在位置取得し表示
- GPSと地図表示を連動
追加機能としては、これ。
- 縮尺を変更可能
- GPS 精度変更
- 逆 geocoding で地名表示
プロジェクト作成
- Xcode11 を起動
- 【なぜ?】
- iOS アプリを開発するには xcode を使う必要があるから
- 新規プロジェクトを作成
- [Create a new Xcode project]を選択
- 【なぜ?】
- 新しいアプリケーションを作成するから
- 新しいアプリケーションを作成するとき、最初に1回だけ行えばよい
- プロジェクト種別
- プロジェクト情報
- [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]
- チェックを入れる
- 【なぜ?】
- 自動テスト関連?
- 将来も使わなかもしれないけど、基本的に無害なので入れておけば良い
- 実は良くわかっていない
- プロジェクト情報保存フォルダ
- ${HOME}/github/shinobee/
- 【なぜ?】
- この記事を見てください
- [Create Git repository on my Mac]にチェックを入れる
- 【なぜ?】
- ソースのバージョン管理に git を使用するから
- 将来も使わなかもしれないけど、基本的に無害なので入れておけば良い
- 作成したプロジェクト
今回の到達点
- これで良いのかわからないけど、とりあえず、xcodeのプロジェクトまで作成できた
連載
- 投稿日:2019-11-24T16:20:53+09:00
Mint + SwiftFormatで躓いた話
MintはXcodeGenやSwiftFormat、SwiftLintなど便利なコマンドラインツールをまとめてくれるやつです。
https://github.com/yonaskolb/MintMintのインストール
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 .
と書いてあげる方が良さそうです。
- 投稿日:2019-11-24T16:20:53+09:00
Mint + SwiftFormatでつまづいた話
MintはXcodeGenやSwiftFormat、SwiftLintなど便利なコマンドラインツールをまとめてくれるやつです。
https://github.com/yonaskolb/MintMintのインストール
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 .
と書いてあげる方が良さそうです。
- 投稿日:2019-11-24T14:00:36+09:00
【Fire base+swift】学習�記録(5)xcodeでアプリ申請→公開まで
Firebaseを勉強し始めてから、2週間ほど経ちました。
今までで作成したアプリは全て、サーバーなどよくわからなかったので一人で完結するものでした。
アプリを作る上でいろいろな人が繋がれる物が作りたくて、Firebaseを勉強し始めました。先日簡単ですが、アプリができたので早速アプリ申請に通しました。
わーい!
できた!!! pic.twitter.com/vEV5MjfZZV
— なかむら (@nakamurasssey) November 24, 2019アプリ申請は2回目ですが、やり方忘れており備忘録としてこちらにまとめておきます。
前提として、自分はすでにDeveloper登録しています。アプリ公開まで
①BundleIDの登録
apple developer>Account>Certificates, Identifiers & Profiles>Identifiers>「+ボタン」からBundleIDを登録
https://developer.apple.com/
②MyAPP作成
app store conectのMyAPPの「+ボタン」から新しいアプリを作成
③Xcodeからアプリをアーカイブ
ビルド先を「Generic iOS Device」にして、Archiveしましょう。
これで、app store conectのMyAPPから提出可能な状態になります。
少しハマったポイント
①アイコン画像は透明部分があってはいけない。(pngで作成して、透明部分があると拒否される)
②Appstore用のスクリーンショットは全部用意しないといけない
③MyAPPでカテゴリーやプライスも設定する提出ができれば、自動的にAppStoreに公開されます。
審査が一発で通ることを願って待ちます!
- 投稿日:2019-11-24T10:32:43+09:00
iOS端末の機能を使ったり、状態を管理したりしたいときに読む記事がこちら!
最近クロス開発プラットフォームの実用性を検証していたのですが、その中で、iOSネイティブならではの機能ってクロス開発でどう実装するんだろう?そもそもどんなのがあったっけ?とふと思うことがありましたので、iOSネイティブならではのiOS端末と密接に関わる機能の実装サンプルをまとめてみました。
開発環境はXcode11.0、Swift5.0で、最小構成で実装しています。
正直この記事を見た方が早いな...と思った項目に関してはリンクを掲載させていただいています。どの記事もわかりやくまとまっていて、とても勉強になるものばかりです。目次
- カメラ・アルバム
- 位置情報
- 住所
- 方角
- 端末イベント
- 監視
- 通知
- ネットワーク
- センサー
- スクリーンの明るさ
- 音
- 加速度
- 角加速度
- シェイク
- 歩数
- 認証
- 生体認証
- Sign In with Apple
- 端末システム情報
- モデル
- OS
- バッテリー残量
カメラ・アルバム
端末のカメラ・アルバムを使用する場合の実装です。ここでは写真・動画を撮影してアルバムに保存するまでを実装しています。
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("キーボードが閉じられました") } }通知 - プッシュ通知
こちらをご参照ください。
- https://qiita.com/natsumo/items/ebba9664494ce64ca1b8
- https://qiita.com/k-yamada-github/items/258aec32a0d5b514f1cf
通知 - バッジ付与
標準フレームワークの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
こちらをご参照ください。
- https://qiita.com/shiz/items/5e094910f742c2ad72a4
- https://qiita.com/mejileben/items/df79024ae93971643271
端末システム情報
端末のシステム情報を使用する場合の実装です。ここでは端末のモデル名と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)% """ } }ふぅ...以上です!
- 投稿日:2019-11-24T09:05:00+09:00
【iOS】iOS13のUISearchBarの新機能を試してみる
iOS13では
UISearchBar
に関連する下記のクラスが追加されました。UISearchTextField
https://developer.apple.com/documentation/uikit/uisearchtextfieldUISearchToken
https://developer.apple.com/documentation/uikit/uisearchtokenUISearchTextFieldDelegate
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)このように設定すると下記の様にテキストフィールドの背景色とテキストの色を変更できます。
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※ トークンの色は
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 field’s 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 field’s text, exclusive of any tokens. @see -[<UITextInput> positionWithinRange:atCharacterOffset:]トークン部分を除いたテキストの範囲が設定されているようです。
動作
このような設定をすると
下記のような動きが実現できました。コピー&ペースト
ここからはコピー&ペーストの実装をしていきます。
まず
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/uipasteboardUISearchTextFieldDelegateの実装
まず
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 = selfUIPasteboardへ値の書き込みと読み込み
次に
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 = selfUIPasteboardからの値の取得
最後に
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)
の中で行っています。動作
このような実装をすることで
下記のような動作が実現できました。今回使用したコード
下記に記載もしていますが
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
に関しては
検索キーワードを一つのまとまりとして扱ったりすることができ
使い道がたくさんあるのではないかと思います。ただ現在情報が見つからないため
正式なサンプルやドキュメントなどが
早く出てくると嬉しいですね?もし間違いなどございましたら教えていただけますとうれしいです??♂️
- 投稿日:2019-11-24T00:42:19+09:00
#16 iOS13以降の画面遷移で前の画面に戻れてしまう問題の解決方法1例
- 投稿日:2019-11-24T00:19:43+09:00
#15 TabBarItemをカスタマイズする1例
- 投稿日:2019-11-24T00:10:53+09:00
複雑なUIScrollViewダンジョンをXcode11の新機能で攻略する
はじめに
久しぶりに
UIScreenView
ダンジョンに入ったら攻略できなかったので攻略法の備忘録。背景
たくさんの人が利用しているだろう
SnapKit
をうちでも使用していて、ScrollView
にAutoLayoutかけようと思ったら難しかったです。ポップアップのようなビューを表示していたんですが、SE端末だけ下のタブバー上のナビバーの間の表示領域を飛び出してしまうので、上限がきたらスクロールに切り替えるという実装をすることになりました。
満たしたい条件
- SnapKit を使用(以下ちょいちょいこれ使用の前提で話します)
- ボタンやらラベルやら詰め込まれているViewを使用(スクロールさせたいContentView)
- Viewの指定
- width最大値指定あり(iPad様の限界値を作るため)
- height最大値指定あり(限界まできたらマージン分で止める)
- ContentViewのheightは内部パーツで決まる可変
- 基本中央表示
- height限界値に到達した場合スクロールさせる、かつContentViewのtopはScrollViewのtopに合わせる
色々条件がありますがXcode11からScrollViewの仕様が少し変わったこともあり、基本形と条件指定形をまとめやり方を定着させたい。参考になる方がいれば。
ScrollViewに制約をかけるときの基本
Xcode11からScrollViewは
- FrameLayoutGuide
- ContentLayoutGuide
というのを持つようです。
登場人物
- 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)
中央表示はcontentView
がsuperView
に到達するまでに設定。優先度は下げておく。
(この辺自信がないので指摘頂けると嬉しい)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
に設定していたのでインセットをかける。これで目標条件達成できました。
上記のように、端末サイズ指定や条件分岐を入れなくても
lessThanOrEqualTo
やgreaterThanOrEqualTo
、priority
を使用することで調整できる。
という認識。終わりに
Scrollダンジョン難しい。
だがFrameLayoutGuide
ContentLayoutGuide
というガイドがあるのでレイアウトを作りやすくなりました。
あとSnapKitとAutolayoutの勉強になりました。自信ない部分もあるので間違っていたら申し訳ない、指摘いただきたいです。