- 投稿日:2021-10-31T23:28:08+09:00
[Swift] SwiftUI の TextView から UITextfield を取り出す
前置き 多くの会社で SwiftUI を使っているところが増えてきたのではないでしょうか? 今回は SwiftUI における TextView に関して、iOS13 では使える設定が限られバグも多いですが、なんとかハックして使っていくためのTipsを紹介していきます。 考察 既存の方法について TextView では使いたいオプションが使えないため、UITextfield を UIViewRepresentable でラップするという方法で、SwiftUI 上で使用することが多く見られます。 今回は、TextView の内部にある UITextfield を取り出すアプローチを取ります。 既に取り出す用のライブラリもありますが、中身がだいぶブラックボックスで将来的に負債になりそうなので、個人的にはあまりオススメはしません。 NotificationCenter を使って監視する UITextfield は以下の3つを使って監視することができます。 TextView が内部に持っている UITextfield の Notification は監視することで、SwiftUI 上で UITextfield を取り出すことができるという仕組みです。 ただし、お気づきの方もいるかもしれませんが、OSとして用意されているものが 入力開始時 入力中 入力終了 この3つしかないため、そのタイミングでのみしか拡張できません。 実装を見ていくと、SwiftUI 上では onReceive で受け取れるので、そのためにPublisherに変換したものを用意します。 extension UITextField { static var textDidBeginEditingNotificationPublisher: NotificationCenter.Publisher { NotificationCenter.default.publisher(for: UITextField.textDidBeginEditingNotification) } static var textDidEndEditingNotificationPublisher: NotificationCenter.Publisher { NotificationCenter.default.publisher(for: UITextField.textDidEndEditingNotification) } static var textDidChangeNotificationPublisher: NotificationCenter.Publisher { NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification) } } 今回は UITextField の extension として拡張しましたが、個々にやりやすいようにしてください。これを TextView の onReceive で監視することで UITextField を検知することができます。 TextField("holder", text: /* Binding<String> */) .onReceive( UITextField.textDidBeginEditingNotificationPublisher, perform: { notification in var textField = (notification.object as? UITextField) . . } ) このようにして UITextField を取り出すことができます。 実用例 1. キーボードのリターンキーを変更する iOS15 からは変更可能ですが、iOS13,14 はreturnKeyを設定できないので、先程の実装を使って実現します。 TextField("holder", text: /* Binding<String> */) .onReceive( UITextField.textDidBeginEditingNotificationPublisher, perform: { notification in (notification.object as? UITextField)?.returnKeyType = .done } ) textDidBeginEditingNotificationPublisher で編集開始時にキーボードの設定を変更しています。 汎用的に使いたいので、Modifierとして切り出すのがオススメです。 struct ReturnKey: ViewModifier { var returnKeyType: UIReturnKeyType func body(content: Content) -> some View { content .onAppear() // `onAppear`がないと、`Modifier`内の`onReceive`が発火しない .onReceive( UITextField.textDidBeginEditingNotificationPublisher, perform: { ($0.object as? UITextField)?.returnKeyType = returnKeyType } ) } } TextField("holder", text: /* Binding<String> */) .modifier(ReturnKey(returnKeyType: .done)) かなりコードがスッキリしました。 2. テキストが未入力時はエンターキーを押させないようにする 基本的には1と同じです。 TextField("holder", text: /* Binding<String> */) .onReceive( UITextField.textDidBeginEditingNotificationPublisher, perform: { notification in (notification.object as? UITextField)?.enablesReturnKeyAutomatically = true } ) 同じように Modifier として切り出してあげると良いでしょう。 struct ReturnKeyAutomaticallyEnable: ViewModifier { var enable: Bool func body(content: Content) -> some View { content .onAppear() // `onAppear`がないと、`Modifier`内の`onReceive`が発火しない .onReceive( UITextField.textDidBeginEditingNotificationPublisher, perform: { ($0.object as? UITextField)?.enablesReturnKeyAutomatically = enable } ) } } TextField("holder", text: /* Binding<String> */) .modifier(ReturnKeyAutomaticallyEnable(enable: true)) 1、2 を見てお気づきだと思いますが、このように TextView にないものは拡張していくことで設定できるようになります。とはいえ、冒頭で述べた通り、設定できるタイミングが3つしかないためで、全てを拡張できるわけではありません。 3. Delegate を設定する どうしても細かいハンドリングをしたいことが出てきた場合は、UITextFieldDelegate を設定しましょう。その際、structにはつけれないので、classで持つことになります。 final class ViewModel: NSObject, UITextFieldDelegate { // do something handling } struct XxxView: View { private let viewModel = ViewModel() TextField("holder", text: /* Binding<String> */) .onReceive( UITextField.textDidBeginEditingNotificationPublisher, perform: { notification in (notification.object as? UITextField)?.delegate = viewModel } ) } 画面跨いだりすると再描画が走ってdelegateが勝手に取れるので、特に解除の処理を入れたりはしていませんが、textDidEndEditingNotificationPublisherでnilを入れてあげても良いかもしれません。 終わりに iOS13では、他にも日本語周りのバグなど使い勝手の悪さがかなりあります。 TextView が少しでも使いやすくなったのであれば幸いですmm
- 投稿日:2021-10-31T15:57:53+09:00
Swiftでバーコードリーダー機能を実装
Swiftでバーコードリーダー機能を実装する方法です。 目標 iPhoneのカメラで本の帯の裏に書かれているバーコードを読み込み、13桁の数字のJANコード(isbn13とも言う)を取得する。 実際に実装するコード 以下の6つのステップでご説明します。 モジュールをimport 変数・定数を定義 セッションを開始する関数を定義・呼び出し delegateの設定 画面から離れる直前にセッションを止める 検出エリアに枠線を表示 それでは順番に見ていきましょう。 1. モジュールをimport import AVFoundation まずカメラアプリを導入するために必要なAVFoundationをimportします。 2. 変数・定数を定義 var captureSession : AVCaptureSession? var videoLayer : AVCaptureVideoPreviewLayer? var isbn : String? //検出エリアをカスタマイズする場合のみ使用します let x: CGFloat = 0.05 let y: CGFloat = 0.4 let width: CGFloat = 0.9 let height: CGFloat = 0.15 次に必要な変数・定数を設定します。 AVCaptureSessionとは 画像や動画といった出力データの管理を行うクラス AVCaptureVideoPreviewLayerとは カメラが取得した映像を画面に表示させるクラス の意味があります。 またカメラ画面に四角い枠を表示させて、検出エリアを設定したい場合は下4行の定数も書いておきましょう。(そっちの方がユーザービリティは高そう) 3. セッションを開始する関数を定義・呼び出し func startCapture(){ //画像や動画といった出力データの管理を行うクラス let session = AVCaptureSession() //カメラデバイスの管理を行うクラス guard let device : AVCaptureDevice = AVCaptureDevice.default(for: .video) else { return } //AVCaptureDeviceをAVCaptureSessionに渡すためのクラス guard let input : AVCaptureInput = try? AVCaptureDeviceInput(device: device) else { return } //inputをセッションに追加 session.addInput(input) //outputをセッションに追加 let output = AVCaptureMetadataOutput() session.addOutput(output) //取得したメタデータを置くAVCaptureMetadataOutputの設定(delegateの設定) output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) //取得したメタデータを置くAVCaptureMetadataOutputの設定(何を検出するか JANコードの場合はean8とean13、他にもqrやcode93などがある) output.metadataObjectTypes = [.ean8, .ean13] //バーコードの検出エリアの設定(設定しない場合、画面全体が検出エリアになる) output.rectOfInterest = CGRect(x: y,y: 1-x-width,width: height,height: width) //セッションを開始 session.startRunning() //画面上にカメラの映像を表示するためにvideoLayerを作る let videoLayer = AVCaptureVideoPreviewLayer(session: session) videoLayer.videoGravity = .resizeAspectFill videoLayer.frame = self.view.bounds //videoLayerを最初に宣言した定数に追加する self.videoLayer = videoLayer self.view.layer.addSublayer(videoLayer) //開放用に保持 self.captureSession = session } //startCapture関数の呼び出し override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.startCapture() } startCapture関数はviewDidAppearで呼び出します。 今回はバーコードを読み取るためにmetadataObjectTypesでean8とean13を設定していますが、他にもqrやpdf417といった種類もあります。 詳しくは公式リファレンスをご覧ください。 4. delegateの設定 startCapture関数でAVCaptureMetadataOutputObjectsDelegateのdelegateをselfに設定しているので、必要なmetadataOutput関数を書きます。 func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection){ //バーコードが検出されたら呼び出される for metadataObject in metadataObjects { guard self.videoLayer?.transformedMetadataObject(for: metadataObject) is AVMetadataMachineReadableCodeObject else { continue } guard let object = metadataObject as? AVMetadataMachineReadableCodeObject else { continue } guard let detectionString = object.stringValue else { continue } //冒頭の文字や文字数を検出して、正しいコードの場合のみ処理が行われるようにしています //例えば書籍のJANコード(isbn13)は978から開始される13桁の数字ですので以下のように書いています if detectionString.starts(with: "978") && detectionString.count == 13 { self.isbn = detectionString } } //self.isbnが空じゃない時 if self.isbn != nil { print("取得したコードは\(self.isbn)です!") //取得し終わったらセッションを止めて空にします self.captureSession?.stopRunning() self.captureSession = nil } } 5. 画面から離れる直前にセッションを止める override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.captureSession?.stopRunning() self.captureSession = nil } 画面から離れるときにはcaptureSessionを止めた上で空にしておきます。 6. 検出エリアに枠線を表示 今の段階では検出エリアをカスタマイズしても、ユーザーからはそのエリアを認識できません。 そのため検出エリアと同じ場所に枠線を表示させて、エリアを分かりやすくする必要があります。 //枠線の表示 let loadArea = UIView() loadArea.frame = CGRect(x: view.frame.size.width * x, y: view.frame.size.height * y, width: view.frame.size.width * width, height: view.frame.size.height * height) loadArea.layer.borderColor = UIColor.red.cgColor loadArea.layer.borderWidth = 4 loadArea.layer.cornerRadius = 6 loadArea.clipsToBounds = true self.view.addSubview(loadArea) 枠線を表示させるコードはstartCaptureを呼び出した後に書きましょう。 全体コード 最後に今回書いたコードをまとめると以下のようになります。 import UIKit import AVFoundation class CaptureViewController : UIViewController { let detectionArea = UIView() let x: CGFloat = 0.05 let y: CGFloat = 0.4 let width: CGFloat = 0.9 let height: CGFloat = 0.15 var captureSession : AVCaptureSession? var videoLayer : AVCaptureVideoPreviewLayer? var isbn : String? override func viewDidLoad() { super.viewDidLoad() self.title = "バーコード読み取り" self.view.backgroundColor = .white self.navigationItem.rightBarButtonItem = { let btn = UIBarButtonItem(title: "完了", style: .plain, target: self, action: #selector(onPressComplete(_:))) return btn }() } @objc func onPressComplete(_ sender : Any){ self.captureSession?.stopRunning() self.captureSession = nil self.dismiss(animated: true, completion: nil) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.captureSession?.stopRunning() self.captureSession = nil } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.startCapture() //枠線の表示 loadArea.frame = CGRect(x: view.frame.size.width * x, y: view.frame.size.height * y, width: view.frame.size.width * width, height: view.frame.size.height * height) loadArea.layer.borderColor = UIColor.red.cgColor loadArea.layer.borderWidth = 4 loadArea.layer.cornerRadius = 6 loadArea.clipsToBounds = true self.view.addSubview(loadArea) } func startCapture(){ //画像や動画といった出力データの管理を行うクラス let session = AVCaptureSession() //カメラデバイスの管理を行うクラス guard let device : AVCaptureDevice = AVCaptureDevice.default(for: .video) else { return } //AVCaptureDeviceをAVCaptureSessionに渡すためのクラス guard let input : AVCaptureInput = try? AVCaptureDeviceInput(device: device) else { return } //inputをセッションに追加 session.addInput(input) //outputをセッションに追加 let output = AVCaptureMetadataOutput() session.addOutput(output) //取得したメタデータを置くAVCaptureMetadataOutputの設定(delegateの設定) output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) //取得したメタデータを置くAVCaptureMetadataOutputの設定(何を検出するか JANコードの場合はean8とean13、他にもqrやcode93などがある) output.metadataObjectTypes = [.ean8, .ean13] //バーコードの検出エリアの設定(設定しない場合、画面全体が検出エリアになる) output.rectOfInterest = CGRect(x: y,y: 1-x-width,width: height,height: width) //セッションを開始 session.startRunning() //画面上にカメラの映像を表示するためにvideoLayerを作る let videoLayer = AVCaptureVideoPreviewLayer(session: session) videoLayer.videoGravity = .resizeAspectFill videoLayer.frame = self.view.bounds //videoLayerを最初に宣言した定数に追加する self.videoLayer = videoLayer self.view.layer.addSublayer(videoLayer) //開放用に保持 self.captureSession = session } } extension CaptureViewController : AVCaptureMetadataOutputObjectsDelegate { func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection){ //バーコードが検出されたら呼び出される for metadataObject in metadataObjects { guard self.videoLayer?.transformedMetadataObject(for: metadataObject) is AVMetadataMachineReadableCodeObject else { continue } guard let object = metadataObject as? AVMetadataMachineReadableCodeObject else { continue } guard let detectionString = object.stringValue else { continue } //冒頭の文字や文字数を検出して、正しいコードの場合のみ処理が行われるようにしています //例えば書籍のJANコードは978から開始される13桁の数字ですので以下のように書いています if detectionString.starts(with: "978") && detectionString.count == 13 { self.isbn = detectionString } } //self.isbnが空じゃない時 if self.isbn != nil { print("取得したコードは\(self.isbn)です!") //取得し終わったらセッションを止めて空にします self.captureSession?.stopRunning() self.captureSession = nil } } } 参考 https://motty72.hatenablog.com/entry/2019/01/29/233355 https://ichi.pro/swift-de-ba-ko-do-mataha-qr-ko-do-o-yomitoru-hoho-swift-de-no-puroguramingu-18503546520018 https://qiita.com/_asa08_/items/8562fe79ec6528a61b06 https://www.letitride.jp/entry/2019/12/03/125802
- 投稿日:2021-10-31T15:08:28+09:00
Swiftのハーフモーダル
swiftハーフモーダル ハーフモーダル実装したことがなかったのでしてみた。とても簡単でした。 ハーフモーダルを発火させるボタンと遷移先の画面をそれぞれ用意 //遷移前画面 prepareをoverrideしてあげる。sheet.detentsを複数指定することによってハーフモーダルと通常モーダルをどちらも使える。 override func prepare(for segue: UIStoryboardSegue, sender: Any?) { let next = segue.destination if let sheet = next.sheetPresentationController { sheet.detents = [.medium(),.large()] } } //遷移先画面 IBActionで下記のように設定。 ボタンを押すと、ハーフだったらラージへ、ラージだったらメディアムになるように設定する animateChangesにすることによって、うまくアニメーションしてうごいていくれる。 @IBAction func change(_ sender: Any) { if let sheet = self.sheetPresentationController { if sheet.selectedDetentIdentifier == .medium { sheet.animateChanges { sheet.selectedDetentIdentifier = .large } } else { sheet.animateChanges { sheet.selectedDetentIdentifier = .medium } } } } とても簡単に実装できました。
- 投稿日:2021-10-31T12:21:18+09:00
[swift]SQLite3
import Foundation import SQLite3 /// SQLite3 class SQLite3 { /// Connection private var db: OpaquePointer? /// Statement private var statement: OpaquePointer? /// データベースを開く /// - Parameter path: ファイルパス /// - Returns: 実行結果 @discardableResult func open(path: String) -> Int { let ret = sqlite3_open_v2(path, &self.db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) if ret != SQLITE_OK { print("error sqlite3_open_v2: code=\(ret)") } return Int(ret) } /// ステートメントを生成しないでSQLを実行 /// - Parameter sql: SQL /// - Returns: 実行結果 @discardableResult func exec(_ sql: String) -> Int { let ret = sqlite3_exec(self.db, sql, nil, nil, nil) if ret != SQLITE_OK { let msg = String(cString: sqlite3_errmsg(self.db)!) print("error sqlite3_exec: code=\(ret), errmsg=\(msg)") } return Int(ret) } /// ステートメントの生成 /// - Parameter sql: SQL /// - Returns: 実行結果 @discardableResult func prepare(_ sql: String) -> Int { let ret = sqlite3_prepare_v2(self.db, sql, -1, &self.statement, nil) if ret != SQLITE_OK { let msg = String(cString: sqlite3_errmsg(self.db)!) print("error sqlite3_prepare_v2: code=\(ret), errmsg=\(msg)") } return Int(ret) } /// 作成されたステートメントにInt型パラメーターを生成 /// - Parameters: /// - index: インデックス /// - value: 値 /// - Returns: 実行結果 @discardableResult func bindInt(index: Int, value: Int) -> Int { let ret = sqlite3_bind_int(self.statement, Int32(index), Int32(value)) if ret != SQLITE_OK { let msg = String(cString: sqlite3_errmsg(self.db)!) print("error sqlite3_bind_int: code=\(ret), errmsg=\(msg)") } return Int(ret) } /// 作成されたステートメントにString型(UTF-8)パラメーターを生成 /// - Parameters: /// - index: インデックス /// - value: 値 /// - Returns: 実行結果 @discardableResult func bindText(index: Int, value: String) -> Int { let ret = sqlite3_bind_text(self.statement, Int32(index), (value as NSString).utf8String, -1, nil) if ret != SQLITE_OK { let msg = String(cString: sqlite3_errmsg(self.db)!) print("error sqlite3_bind_text: code=\(ret), errmsg=\(msg)") } return Int(ret) } /// ステートメントの実行(SELECTの場合は行の取得) /// - Returns: 実行結果 @discardableResult func step() -> Int { let ret = sqlite3_step(self.statement) if ret != SQLITE_ROW && ret != SQLITE_DONE { let msg = String(cString: sqlite3_errmsg(self.db)!) print("error sqlite3_step: code=\(ret), errmsg=\(msg)") } return Int(ret) } /// ステートメントのリセット /// - Returns: 実行結果 @discardableResult func resetStatement() -> Int { let ret = sqlite3_reset(self.statement) if ret != SQLITE_OK { let msg = String(cString: sqlite3_errmsg(self.db)!) print("error sqlite3_reset: code=\(ret), errmsg=\(msg)") } return Int(ret) } /// ステートメントの破棄 /// - Returns: 実行結果 @discardableResult func finalizeStatement() -> Int { let ret = sqlite3_finalize(self.statement) if ret != SQLITE_OK { let msg = String(cString: sqlite3_errmsg(self.db)!) print("error sqlite3_finalize: code=\(ret), errmsg=\(msg)") } return Int(ret) } /// SELECTしたステートメントからInt値を取得 /// - Parameter index: カラムインデックス(0から) /// - Returns: Int値 func columnInt(index: Int) -> Int { return Int(sqlite3_column_int(self.statement, Int32(index))) } /// SELECTしたステートメントからString値を取得 /// - Parameter index: カラムインデックス(0から) /// - Returns: String値 func columnText(index: Int) -> String { return String(cString: sqlite3_column_text(self.statement, Int32(index))) } /// データベースを閉じる /// - Returns: 実行結果 @discardableResult func close() -> Int { let ret = sqlite3_close_v2(self.db) if ret != SQLITE_OK { let msg = String(cString: sqlite3_errmsg(self.db)!) print("error sqlite3_close_v2: code=\(ret), errmsg=\(msg)") } return Int(ret) } /// テーブル存在チェック /// - Parameters: /// - sqlite3: SQLite3 /// - table: テーブル名 func existsTable(_ tableName: String) -> Bool { self.prepare("SELECT COUNT(*) AS CNT FROM sqlite_master WHERE type = ? and name = ?") defer { self.finalizeStatement() } self.bindText(index: 1, value: "table") self.bindText(index: 2, value: tableName) if self.step() == SQLITE_ROW { if self.columnInt(index: 0) > 0 { return true } } return false } } let sqlite3 = SQLite3() sqlite3.open(path: "/Users/xxxxx/test.sqlite3") sqlite3.exec(""" CREATE TABLE T_TEST ( IntField INTEGER NOT NULL, TextField TEXT NOT NULL, PRIMARY KEY (IntField) ) """) sqlite3.prepare("INSERT INTO T_TEST VALUES (?, ?)") for i in 1...5 { sqlite3.bindInt(index: 1, value: i) sqlite3.bindText(index: 2, value: String("number\(i)")) if sqlite3.step() != SQLITE_DONE { print("error: INSERT") } sqlite3.resetStatement() } sqlite3.prepare("SELECT * FROM T_TEST WHERE IntField > ?") sqlite3.bindInt(index: 1, value: 3) while sqlite3.step() == SQLITE_ROW { let intField = sqlite3.columnInt(index: 0) let textField = sqlite3.columnText(index: 1) print("IntField:\(intField), TextField:\(textField)") } sqlite3.close()
- 投稿日:2021-10-31T11:35:06+09:00
iOS14で非推奨となったUITableViewCellのtextLavelプロパティ等の対応(iOS13対応含)
経緯 iOS14でUITableViewCellのtextLabelプロパティ等が非推奨(Deprecated)になりました。代わりに、defaultContentConfiguration等を使用するのですが、こちらはiOS13非対応のため、現状iOS13以前に対応しているアプリだと使えません。 Xcodeが助け舟を出してくれますが、念の為解決方法をメモしておきます。 対応方法 // クラス名は任意。TableViewControllerを使わない場合、親クラスはUITableViewDataSource class TableViewController: UITableViewController { let itemArray = ["A", "B", "C"] override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { itemArray.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // withIdentifierは任意 let cell = tableView.dequeueReusableCell(withIdentifier: "ToDoItemCell", for: indexPath) if #available(iOS 14.0, *) { // iOS14以降の推奨 var content = cell.defaultContentConfiguration() content.text = itemArray[indexPath.row] cell.contentConfiguration = content } else { // iOS13以前 cell.textLabel?.text = itemArray[indexPath.row] } return cell } } 結果 (これまでと変わりません) 参考
- 投稿日:2021-10-31T11:24:06+09:00
[macos][swift]管理者でプロセス実行
import Foundation let sudo = Process() // ls を実行してみる // -S: パスワードを端末ではなく標準入力から読み込む sudo.launchPath = "/usr/bin/sudo" sudo.arguments = ["-S", "/bin/ls"] // パイプを設定 let stdin = Pipe() let stdout = Pipe() sudo.standardOutput = stdout sudo.standardError = stdout sudo.standardInput = stdin // 実行 sudo.launch() // パスワードの末尾には改行が必要 let password = "password" let passwordWithNewline = password + "\n" // パスワードを標準入力へ書き込み stdin.fileHandleForWriting.write(passwordWithNewline.data(using: .utf8)!) try? stdin.fileHandleForWriting.close() // 終了まで待機 sudo.waitUntilExit() // 標準出力を取得 if let data = try? stdout.fileHandleForReading.readToEnd(), let output = String(data: data, encoding: .utf8) { print(output) }