- 投稿日:2021-01-23T23:37:44+09:00
[Swift]UISliderクラス
Sliderで透過率を変更する
UISliderクラスを実装することで,理解していきます。
準備
実装
+ボタン(Library)→showtheimagelibraryからflowerを選択して、ViewControllerに配置します
UISliderをViewControllerに接続する時に、ConnectionをActionに変更して、TypeをUISliderに変更します。
以下のように、コードを書いて実行すると写真のalphaを変更することができます。ViewController.swiftimport UIKit class ViewController: UIViewController { @IBOutlet weak var flower: UIImageView! @IBAction func UISlider(_ sender: UISlider) { flower.alpha=CGFloat(sender.value) } override func viewDidLoad() { super.viewDidLoad() } }
- 投稿日:2021-01-23T22:53:30+09:00
Float5で浮動小数点数を振り返る
浮動小数点数の特徴を振り返ってみます。
ビット列
コンピューターの最終単位はビット。0か1です。しかし、0か1だけでは表現の幅がないので、複数の0か1を組み合わせることで数字など表します。ここでは、5ビットの数字を考えてみます。
※どうして5ビットにしたかというと、全部のビットの組み合わせを表示できるというだけで、実用的には全く意味がありません。なお、本記事中、数字の後に2とあるのは、2進数という意味です。UInt5
5ビットで考えられる基本的な数字と云えば、符号なし整数「UInt5」です。
ビット列 数字 ビット列 数字 ビット列 数字 ビット列 数字 00000 0 01000 8 10000 16 11000 24 00001 1 01001 9 10001 17 11001 25 00010 2 01010 10 10010 18 11010 26 00011 3 01011 11 10011 19 11011 27 00100 4 01100 12 10100 20 11100 28 00101 5 01101 13 10101 21 11101 29 00110 6 01110 14 10110 22 11110 30 00111 7 01111 15 10111 23 11111 31 これは、000002を0と定義し、1増やすごとに「最下位ビットが0なら1にする。最下位ビットが1なら0にして、一つ上のビットを確認する。そのビットが0なら1にする。1なら0にしてもう一つ上のビットを・・・」の繰り返しです。最終的に111112になり、さらに1増やすと000002に戻ります。
現在一般的なパソコン用CPUは、このような手順でUInt8, UInt16,...を定義しているかと思います。Int5
符号なし整数よりもよく使われるのが符号あり整数「Int5」です。
Int5(2の補数表現)
ビット列 10進数 ビット列 10進数 ビット列 10進数 ビット列 10進数 00000 0 01000 8 10000 -16 11000 - 8 00001 1 01001 9 10001 -15 11001 - 7 00010 2 01010 10 10010 -14 11010 - 6 00011 3 01011 11 10011 -13 11011 - 5 00100 4 01100 12 10100 -12 11100 - 4 00101 5 01101 13 10101 -11 11101 - 3 00110 6 01110 14 10110 -10 11110 - 2 00111 7 01111 15 10111 - 9 11111 - 1 「2の補数表現」と呼ばれる定義です。000002から011112まではUInt5と同じ、011112と100002の間を除けば1ずつ増えていくところもUInt5と同じです。
「100002は-1610ではなく+1610と定義してもよいのでは?」とも思いますが、-1610と定義しておくと「最上位ビットが1なら負の数である」と統一できるので、比較演算などの時に便利です。Int5(符号+絶対値表現)
ほかにも
ビット列 10進数 ビット列 10進数 ビット列 10進数 ビット列 10進数 00000 0 01000 8 10000 0 11000 - 8 00001 1 01001 9 10001 - 1 11001 - 9 00010 2 01010 10 10010 - 2 11010 -10 00011 3 01011 11 10011 - 3 11011 -11 00100 4 01100 12 10100 - 4 11100 -12 00101 5 01101 13 10101 - 5 11101 -13 00110 6 01110 14 10110 - 6 11110 -14 00111 7 01111 15 10111 - 7 11111 -15 という定義も考えられます。最上位ビットで符号をあらわし、それ以外のビットで絶対値を表しています。
人が見るとわかりやすいのですが、コンピューターが計算するには少々やっかいなところがあります。2の補数表現で 510+(-310)=210 をあらわすと、001012 + 111012 = 000102 になります。
符号+絶対値表現で 510+(-310)=210 をあらわすと、 001012 + 100112 = 000102 になります。
ここで、同じビット列のUInt5の足し算は、
001012 + 111012 = 000102 (左辺が2の補数表現の510+(-310)と同じビット列)
001012 + 100112 = 110002 (左辺が符号+絶対値表現の510+(-310)と同じビット列)
となり、2の補数表現の場合とUInt5の場合の右辺が同じになります。つまり、2の補数表現を使えば、UInt5とInt5で同じ加算処理を使えるのでCPU設計が簡単になるかと思います。Int5(バイアス表現)
ビット列 10進数 ビット列 10進数 ビット列 10進数 ビット列 10進数 00000 -15 01000 - 7 10000 1 11000 9 00001 -14 01001 - 6 10001 2 11001 10 00010 -13 01010 - 5 10010 3 11010 11 00011 -12 01011 - 4 10011 4 11011 12 00100 -11 01100 - 3 10100 5 11100 13 00101 -10 01101 - 2 10101 6 11101 14 00110 - 9 01110 - 1 10110 7 11110 15 00111 - 8 01111 0 10111 8 11111 16 という定義もできます。UInt5から15を引いた(バイアスを加えた)、という定義です。000002から111112まで1ずつ増加するので大小の比較が簡単になりますが、符号+絶対値表現と同様に、UInt5とは別の加算処理を行う必要があります。
Float5
IEEE754 にある binary16を参考に、Float5を定義してみます。
表現したい数字cを、c = ± a × 2b という指数表現にし、Float5の最上位ビットは符号、続く2ビットを指数部b、下位2ビットを仮数部aに割り当てます。
符号は、正を0、負を1で表します。これは、2の補数表現や符号+絶対値表現と同じです。
指数部bは、以下のように割り当てます。
ビット列 10進数 00 -1 01 0 10 1 11 2 これは、Int5の最後に紹介したバイアス表現と同じです。
続いて、仮数部aは、以下のように割り当てます。
ビット列 10進数 2進数 補足 00 1.00 1.00 指数部が00の場合は0.00 01 1.25 1.01 10 1.50 1.10 11 1.75 1.11 指数部が11の場合は無限大 10進数表現がわかりづらいので、2進数表現もつけました。10進数の場合、指数表現では(整数部1桁.小数部)×10指数部と表現します。2進数の場合も同様に整数部を1桁で表すのですが、1桁に0か1しかありませんので、表現したい値cが0の場合を除けば、整数部は必ず1になります。そこで、整数部を1に固定し、小数点以下2桁にビット列を割り当てます。これで有効桁数を若干増やしています。
さて、以上を踏まえると、Float5は以下のようになります。
ビット列 10進数 ビット列 10進数 ビット列 10進数 ビット列 10進数 00000 +0.00×2-1=0.000 01000 +1.00×21=2.000 10000 -0.00×2-1=-0.000 11000 -1.00×21=-2.000 00001 +1.25×2-1=0.625 01001 +1.25×21=2.500 10001 -1.25×2-1=-0.625 11001 -1.25×21=-2.500 00010 +1.50×2-1=0.750 01010 +1.50×21=3.000 10010 -1.50×2-1=-0.750 11010 -1.50×21=-3.000 00011 +1.75×2-1=0.875 01011 +1.75×21=3.500 10011 -1.75×2-1=-0.875 11011 -1.75×21=-3.500 00100 +1.00×2 0=1.000 01100 +1.00×22=4.000 10100 -1.00×2 0=-1.000 11100 -1.00×22=-4.000 00101 +1.25×2 0=1.250 01101 +1.25×22=5.000 10101 -1.25×2 0=-1.250 11101 -1.25×22=-5.000 00110 +1.50×2 0=1.500 01110 +1.50×22=6.000 10110 -1.50×2 0=-1.500 11110 -1.50×22=-6.000 00111 +1.75×2 0=1.750 01111 +無限大 10111 -1.75×2 0=-1.750 11111 -無限大 さて、浮動小数点数を扱うときに気をつけなければならないことの一つに、10進数の小数がそのまま表せないと云うことがあります。見てのとおり、0.110刻みの数値を表すことはできません。意外にも、「仮数部2桁の2進数だから、0.2510, 0.5010, 0.7510は表現できる」と思いきや、0.7510しか表現できません。
そして、もう一つ気をつけないといけないのが、数値の間隔(分解能)です。000012から000112までは各値の間隔が0.2510です。しかし、011002から011102までは間隔が1.0010になります。このように、指数部が大きくなるごとに数値の間隔が広くなってきます(ただし、000002から000012の間だけ広くなっています)。
まとめ
浮動小数点数といえば、「10進数の小数が表せないことがある」点が取り上げられがちですが、「0から離れるほど数値の間隔が広くなる(分解能が落ちる)」ということも気をつける必要がありそうです。
- 投稿日:2021-01-23T21:19:14+09:00
AVFoundation 音再生
音再生方法
まず AVFoundation をインポート
viewController.swiftimport UIKit import AVFoundation class ViewController: UIViewController,AVAudioPlayerDelegate { var player :AVAudioPlayer!まず、音ファイルをXcodeに追加。追加するのはここ!!
今回は
umbrella と souziki が入っていますね。
音声を呼び出す用の関数を定義!
viewController.swiftpublic func prepareSound() { let soundFilePath = Bundle.main.path(forResource: "umbrella", ofType: "mp3")! let sound:URL = URL(fileURLWithPath: soundFilePath) do { player = try AVAudioPlayer(contentsOf: sound, fileTypeHint: nil) player?.delegate = self } catch { print("イエラー") } player?.delegate = self player?.prepareToPlay() }viewDidLoad()に関数を記入
viewController.swiftoverride func viewDidLoad() { super.viewDidLoad() prepareSound() }あとは、ボタンなど、呼び出したいところで下記のオードを書けば
viewController.swift@IBAction func button(_ sender: Any) { if ((player?.isPlaying) != nil) { player?.stop() player?.currentTime = 0 } player?.play() }現在、選択私の違いによって出る音の変わる仕組みを製作中。
- 投稿日:2021-01-23T21:05:06+09:00
Swift Touchイベント処理でお絵かきアプリを作ってみました
Touchイベントの勉強をきっかけにこちらの記事を見て作ってみました。
記事に書かれている主機能の他に消しゴム、ペン/消しゴムのサイズの変更を追加しました。ViewControllerはこのような構成
class ViewController: UIViewController { @IBOutlet weak var drawView: DrawView! ///お絵かきのカスタムView @IBOutlet weak var eraserSelected: UISegmentedControl! @IBOutlet weak var selectColor: UISegmentedControl! @IBOutlet weak var selectSize: UISegmentedControl! @IBAction func undoTapped(_ sender: Any) { } @IBAction func clearTapped(_ sender: Any) { } @IBAction func penOrEraser(_ sender: UISegmentedControl) { } @IBAction func penTapped(_ sender: UISegmentedControl) { } @IBAction func sizeSelected(_ sender: UISegmentedControl) { } }サイズの種類を定義するEnum
enum size { case small case middle case big }一描きの要素を含むDrawing
struct Drawing { var points = [CGPoint]() var color = UIColor.black var size:size? = .small }タッチイベントの処理、主機能の処理のソースコードが詰まっている
カスタムビューDrawViewはこのような構成class DrawView: UIView{ var finishedDrawings = [Drawing]()///完成したかきデータ var currentDrawing: Drawing?///進行中のかきデータ var currentColor = UIColor.black var currentSize: size? var eraserSelected = false override func draw(_ rect: CGRect) { strokeToLine()///完成したデータと進行中のデータのDrawingを一つずつ処理していく } override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { ///タッチポイントを取得してcurrentDrawingへ入れる setNeedsDisplay() ///setNeedsDisplayによってdrawが自動的に呼び出されてタッチが処理されていく } override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { ///同上 setNeedsDisplay() } override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { ///同上、currentDrawingを完成データへ入れる setNeedsDisplay() } func strokeToLine(drawing:Drawing){ ///drawingのpointsを線で繋ぐ処理 let path = UIBezierPath()///宣言 path.move(to: beginPoint)///開始点 path.addLine(to: endPoint)///終了点 path.stroke()///描画する } func clear(){ } func undo(){ } func setColor(_ color: UIColor){ } func setSize(_ newSize:size){ } }完成したソースコードは以下になります。
ViewController.swiftimport UIKit class ViewController: UIViewController { @IBOutlet weak var drawView: DrawView! @IBOutlet weak var eraserSelected: UISegmentedControl! @IBOutlet weak var selectColor: UISegmentedControl! @IBOutlet weak var selectSize: UISegmentedControl! override func viewDidLoad() { super.viewDidLoad() } @IBAction func undoTapped(_ sender: Any) { drawView.undo() } @IBAction func clearTapped(_ sender: Any) { drawView.clear() } @IBAction func penOrEraser(_ sender: UISegmentedControl) { switch eraserSelected.selectedSegmentIndex{ case 0: drawView.eraserSelected = false case 1: drawView.eraserSelected = true default: break } } @IBAction func penTapped(_ sender: UISegmentedControl) { var c = UIColor.black switch selectColor.selectedSegmentIndex { case 1: c = UIColor.red case 2: c = UIColor.yellow default: break } drawView.setColor(c) } @IBAction func sizeSelected(_ sender: UISegmentedControl) { switch selectSize.selectedSegmentIndex { case 0: drawView.setSize(.small) case 1: drawView.setSize(.middle) case 2: drawView.setSize(.big) default: break } } }DrawView.swiftclass DrawView: UIView{ var finishedDrawings = [Drawing]() var currentDrawing: Drawing? var currentColor = UIColor.black var currentSize: size? var eraserSelected = false override func draw(_ rect: CGRect) { for drawing in finishedDrawings{ drawing.color.setStroke() strokeToLine(drawing: drawing) } if let drawing = currentDrawing{ drawing.color.setStroke() strokeToLine(drawing: drawing) } } override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { let point = touches.first!.location(in: self) currentDrawing = Drawing() if eraserSelected { currentDrawing?.color = UIColor.white }else{ currentDrawing?.color = currentColor } currentDrawing?.size = currentSize currentDrawing?.points.append(point) setNeedsDisplay() } override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { let point = touches.first!.location(in: self) currentDrawing?.points.append(point) setNeedsDisplay() } override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { let point = touches.first!.location(in: self) if var drawing = currentDrawing { drawing.points.append(point) finishedDrawings.append(drawing) } currentDrawing = nil setNeedsDisplay() } func strokeToLine(drawing:Drawing){ var width:CGFloat = 6.0 switch drawing.size{ case .middle: width = 12.0 case .big: width = 20.0 default: width = 6.0 } let path = UIBezierPath() path.lineCapStyle = .round path.lineJoinStyle = .round path.lineWidth = width let beginPoint = drawing.points[0] path.move(to: beginPoint) if drawing.points.count > 1{ for i in 1...(drawing.points.count - 1){ let endPoint = drawing.points[i] path.addLine(to: endPoint) } } path.stroke() } func clear(){ finishedDrawings.removeAll() setNeedsDisplay() } func undo(){ if finishedDrawings.count == 0 { return } finishedDrawings.remove(at: finishedDrawings.count - 1) setNeedsDisplay() } func setColor(_ color: UIColor){ currentColor = color } func setSize(_ newSize:size){ currentSize = newSize } }
- 投稿日:2021-01-23T15:40:28+09:00
【Swift】UITextViewで文字数制限と行数制限を行う方法
はじめに
クイズアプリを作成する上でTextViewを使用したので、
その時に学んだことを備忘録がてら共有できたらなと思います。環境
・Swift version 5.3
・XCode version 12.3完成形
gitや画像を載せようと思ったのですが、月の上限を超していたため載せれませんでした・・・。
申し訳ございませんが文字のみになってしまいます。完成形の機能としては下記のような機能が実装されています。
・プレースホルダー
TextFieldのように簡単にプレースホルダーを定義できないTextViewですが、
プレースホルダーの実装をしてみました。
・文字数制限と文字数カウント
今回は50文字とういう制限で実装しました。
・行数制限
今回は7行という制限で実装しました。
・キーボードの非表示
UITapGestureRecognizerを使い実装しました。コード
全てコピペしてもうまく実行されません。
@IBOutlet
やその変数に関する箇所はご自身の内容にしたがってください。ViewController.swiftimport UIKit class ViewController: UIViewController, UITextViewDelegate { // storyboardのオブジェクトと紐付け // 文字入力箇所 @IBOutlet weak var questionTextView: UITextView! // 何文字入力されたか表示するラベル @IBOutlet weak var textCountLabel: UILabel! private let placeholder = "問題文を入力してください。\n\n問題文は、50文字・7行以内で記載してください。" private let textLength = 50 override func viewDidLoad() { super.viewDidLoad() questionTextView.delegate = self questionTextView.text = placeholder //タップでキーボードを下げる let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) self.view.addGestureRecognizer(tapGesture) //下にスワイプでキーボードを下げる let swipeDownGesture = UISwipeGestureRecognizer(target: self, action: #selector(dismissKeyboard)) swipeDownGesture.direction = .down self.view.addGestureRecognizer(swipeDownGesture) } @objc func dismissKeyboard() { self.view.endEditing(true) } // 文字数制限&行数制限 func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { //既に存在する改行数 let existingLines = textView.text.components(separatedBy: .newlines) //新規改行数 let newLines = text.components(separatedBy: .newlines) //最終改行数。-1は編集したら必ず1改行としてカウントされるから。 let linesAfterChange = existingLines.count + newLines.count - 1 return linesAfterChange <= 7 && questionTextView.text.count + (text.count - range.length) <= textLength } // TextViewの内容が変わるたびに実行される func textViewDidChange(_ textView: UITextView) { //既に存在する改行数 let existingLines = textView.text.components(separatedBy: .newlines) if existingLines.count <= 7 { self.textCountLabel.text = "\(questionTextView.text.count) / \(textLength)" } } // 入力開始時にプレースホルダーの内容が入っていたら空にする func textViewDidBeginEditing(_ textView: UITextView) { if textView.text == placeholder { textView.text = nil textView.textColor = .darkText } } // 入力終了後に文字が入力されていなかったらプレースホルダー表示 func textViewDidEndEditing(_ textView: UITextView) { if textView.text.isEmpty { textView.text = placeholder textView.textColor = .darkGray } } }コード説明
デリゲートメソッドを使用するためにプロトコルを追加する必要があります。
また、自身のtextViewにデリゲートの処理を委譲してください。class MakeQuizViewController: UIViewController, UITextViewDelegate { override func viewDidLoad() { super.viewDidLoad() questionTextView.delegate = self } }文字数制限と行数制限は下記のメソッドで行っています。
下記のメソッドはreturnの内容がtrueの限りtextViewの編集が可能になるメソッドです。(多分)つまり、返り値がfalseになったら入力ができなくなります。
今回は、入力文字数が50文字以下 かつ 行数が7行以下の場合はtrueが返ります。
行数の取得は文字列を
components(separatedBy:)
メソッドで区切り取得しています。
components(separatedBy:)
メソッドを使用すると返り値が配列で返ってきますので、
existingLines.count
で配列の個数を取得しています。この個数の合計が現在改行されている合計です。
文字数は、
questionTextView.text.count
で現在入力されている文字数に、
(text.count - range.length)
で新しく入力した文字数を足して計算しています。func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { //既に存在する改行数 let existingLines = textView.text.components(separatedBy: .newlines) //新規改行数 let newLines = text.components(separatedBy: .newlines) //最終改行数。-1は編集したら必ず1改行としてカウントされるから。 let linesAfterChange = existingLines.count + newLines.count - 1 return linesAfterChange <= 7 && questionTextView.text.count + (text.count - range.length) <= textLength }入力されるたびにテキストのカウント用のラベルを更新しています。
// TextViewの内容が変わるたびに実行される func textViewDidChange(_ textView: UITextView) { //既に存在する改行数 let existingLines = textView.text.components(separatedBy: .newlines) if existingLines.count <= 7 { self.textCountLabel.text = "\(questionTextView.text.count) / \(textLength)" } }
textViewDidBeginEditing(_ textView:)
メソッドは、
入力開始時にプレースホルダーが入力されている場合は、
プレースホルダを消したいのでtextView.text = nil
を行っています。
textViewDidEndEditing(_ textView:)
メソッドは、
入力終了時に、TextViewの値が空だったらプレースホルダーを入力しています。
func textViewDidBeginEditing(_ textView: UITextView) { if textView.text == placeholder { textView.text = nil textView.textColor = .darkText } } func textViewDidEndEditing(_ textView: UITextView) { if textView.text.isEmpty { textView.text = placeholder textView.textColor = .darkGray } }入力状態でキーボードが開かれるのでそれを閉じるための機能です。
タップかスワイプが行われたら
dismissKeyboard()
メソッドを実行し、
self.view.endEditing(true)
でキーボードを閉じます。//タップでキーボードを下げる let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) self.view.addGestureRecognizer(tapGesture) //下にスワイプでキーボードを下げる let swipeDownGesture = UISwipeGestureRecognizer(target: self, action: #selector(dismissKeyboard)) swipeDownGesture.direction = .down self.view.addGestureRecognizer(swipeDownGesture) @objc func dismissKeyboard() { self.view.endEditing(true) }さいごに
画像などが無い状態での説明でしたのでわかりにくかったらすみません。
そんなに難しいことは行っていないので、
別の方の記事も参考にしながら実装してみてください!以上、最後までご覧いただきありがとうございました。
- 投稿日:2021-01-23T15:40:01+09:00
【KMM】suspend funで定義された通信処理をSwift側でキャンセルできるようにするためにどう実装するか
まずはじめに
Kotlin Multiplatformを利用する機会があったのですが、Swiftから利用できるようにする上で「どのように導入するか」や「試してみた」という参考文献はいくつか存在するかと思います。
しかし、Swiftから利用できるようにするためにもう一歩踏み込んだ実装詳細が記載された参考文献はあまり見つからなかったので、備忘録として投稿していきます。
本投稿では、suspend funで定義された通信処理をSwift側でキャンセルできるようにするためにどう実装するかに絞って記載しています。
※本投稿で利用しているKotlinはv1.4.xで、通信処理にはKtor v1.5.xを利用しています。suspend funで定義された関数をSwiftで利用した場合に
以下のようなGitHubのApiからユーザーIDをもとにユーザー情報を取得するInterfaceを定義したとします。
GitHubApi.ktinterface GitHubApi { @Throws(GitHubApiException::class, CancellationException::class) suspend fun getUser(userId: String): User }上記の定義をSwiftで利用した場合に以下のような実装ができます。
let gitHubApi: GitHubApi = GitHubApiImpl() gitHubApi.getUser( userId: "marty-suzuki", completionHandler: { (user: User?, error: Error?) in //userとerrorを利用して後続の処理を実施 } )Kotlin/Native interoperability with Swift/Objective-C Mappingsにあるように、関数の定義は
getUser(userId: String, completionHandler: (User?, Error?) -> Void)
となり、suspend funの返り値
と@Throwsで指定されているException
がcompletionHandlerのクロージャの引数として取り得る値となります。suspend funの処理をSwift側でキャンセルするには
上記の実装を利用した場合に、ユーザー情報を取得中に画面が破棄された際の処理はどのよう実装すれば良いでしょうか。
Swiftから利用できるのはgetUser(userId: String, completionHandler: (User?, Error?) -> Void)
なので、実装できたとしても画面が破棄さているため描画はされないが通信処理は実行されている
という状態になってしまうかと思います。KtorのIosClientEngineの実装
そもそも通信処理をキャンセルできるのかを確認するために、通信処理に利用しているKtorの実装を見ていきます。
iOSの通信処理に利用されるのはIosClientEngineとなり、以下のような実装となっています。IosClientEngine.ktinternal class IosClientEngine(override val config: IosClientEngineConfig) : HttpClientEngineBase("ktor-ios") { ... override suspend fun execute(data: HttpRequestData): HttpResponseData { val callContext = callContext() val responseReader = IosResponseReader(callContext, data, config) ... return try { responseReader.awaitResponse() } catch (cause: CancellationException) { if (task.state == NSURLSessionTaskStateRunning) { task.cancel() } throw cause } } }
responseReader.awaitResponse()
で通信処理の完了を待っており、キャンセルのExceptionが投げられてURLSessionTaskが実行中だった場合はキャンセルする処理となっています。
つまり内部実装的には通信処理をキャンセルできるようになっているので、Swiftから利用する場合の定義にキャンセルが可能なインターフェースがないことが課題であることがわかります。suspend funをラップする
上記でも述べたようにKotlinでsuspend funとして定義した場合は、Swiftからは返り値がVoidで引数にcompletionHandlerを取る定義しか呼び出せません。
キャンセル可能なインターフェースにするためにはどのような実装にすれば良いのでしょうか。
参考になる記事を探していたところ、Working with Kotlin Coroutines and RxSwiftに記載されている内容が実現したい処理だったので、こちらの記事をベースに必要な部分を実装していきます。前提として、CoroutineScopeのlaunch関数を利用してsuspend funを実行すると、launch関数の返り値のJobを利用してキャンセルをすることができます。
val job = GlobalScope.launch { gitHubApi.getUser("marty-suzuki") } job.cancel()つまり、Jobから処理をキャンセルできることを利用してsuspend funをラップするクラスを実装することで、Swiftからでも処理をキャンセルする定義を呼び出すことができるようになります。
suspend funをラップした実装(以降SuspendWrapper)は以下になります。SuspendWrapper.ktclass SuspendWrapper<T>(private val suspender: suspend () -> T) { // Kotlinからsuspend funとして呼び出せるようにするための関数 suspend fun suspend() = suspender() // Swiftから呼び出す処理をキャンセルできるようにする関数 fun subscribe( onSuccess: (item: T) -> Unit, onThrow: (error: Throwable) -> Unit ): Cancelable { val job = CoroutineScope(Dispatchers.Main).launch { try { onSuccess(suspender()) } catch (error: Throwable) { onThrow(error) } } return object: Cancelable { override fun cancel() { job.cancel() } } } } interface Cancelable { fun cancel() }SuspendWrapperのconstructorにsuspend funのlambdaを引数とします。
関数の定義としては
- Swiftからsuccessとexceptionをcallbackとしてハンドリングできるようにしつつ、キャンセル可能なインターフェースを返り値とするfun
- Kotlinからそのまま利用できるようにするためのsuspend fun
の2つとなります。
GitHubApiにSuspendWrapperを適用すると以下のようなInterfaceとなります。
GitHubApi.ktinterface GitHubApi { fun getUser(userId: String): SuspendWrapper<User> }そして、Swift側から上記を呼び出すと、以下のようにsuccessとexceptionをハンドリングしつつ、キャンセルを実行することができるようになります。
let cancelable = gitHubApi.getUser(userId: "marty-suzuki") .subscribe( onSuccess: { (user: User?) in ... }, onThrow: { (throwable: KotlinThrowable) in ... } ) cancelable.cancel()余談
本投稿の本筋とは若干それるのですが、備忘録として余談も記載していきます。
テストでSuspendWrapperをどう注入するか
Swiftから呼び出し場合にSuspendWrapperのInitializerの定義は
SuspendWrapper(suspender: KotlinSuspendFunction0)
となっています。
KotlinSuspendFunction0はObjective-Cで定義されたprotocolであるため、associatedTypeを持っていません。
よってテストなどでSuspendWrapperを介してFakeを渡す場合に、型が違っていたとしてもランタイムでわかるという形になってしまいます。
そこで、KotlinSuspendFunction0を採用した型パラメータを利用できるSuspendFunctionのFakeを実装することで、Swift側でもコンパイル時に代入ミスなどがエラーで気づけるようにできます。FakeSuspendFunction.swiftfinal class FakeSuspendFunction<T: AnyObject>: KotlinSuspendFunction0 { private let result: Result<T, Error> init(_ result: Result<T, Error>) { self.result = result } func invoke(completionHandler: @escaping (Any?, Error?) -> Void) { switch result { case let .success(value): completionHandler(value, nil) case let .failure(error): completionHandler(nil, error) } } func asSuspendWrapper() -> SuspendWrapper<T> { SuspendWrapper(suspender: self) } }FakeGitHubApi.ktclass FakeGitHubApi: GitHubApi { var user = SuspendWrapper<User> { User("") } override fun getUser(userId: String) = user }let fakeApi = FakeGitHubApi() let fakeUser = FakeSuspendFunction(.success(User(userId: "marty-suzuki"))) fakeApi.user = fakeUser.asSuspendWrapper()RxSwiftで使いやすくするために
現在Kotlin Multiplatformを導入しようとしているプロジェクトではRxSwiftを利用しています。
SuspendWrapper(suspend fun自体も)は処理を1度だけ実行して結果を返します。
その性質をRxSwiftのSingleと同様なため、以下のように変換することができます。enum KotlinError: Error { case invalidResponse case throwable(KotlinThrowable) } extension Single where Element: AnyObject { static func create(_ suspendWrapper: SuspendWrapper<Element>) -> Single<Element> { Single.create { observer in let cancalable = suspendWrapper.subscribe( onSuccess: { observer($0.map(SingleEvent.success) ?? .failure(KotlinError.invalidResponse)) }, onThrow: { observer(.failure(KotlinError.throwable($0))) } ) return Disposables.create { cancalable.cancel() } } } }Kotlin Multiplatformで生成された実装はObjective-Cとなるため、Objective-CでGenericに定義されたクラスの型パラメータをSwift側のextensionから呼び出すことはできません。
そのため、SuspendWrapper<T>.asSingle()
のような実装が容易にできないため、苦肉の策としてカスタムオペレータとしてpostfixなものを実装することで、SuspendWrapperからSingleへの変換が呼び出しやすくなります。postfix operator ~ postfix func ~ <T>(lhs: SuspendWrapper<T>) -> Single<T> { Single.create(lhs) }let disposable = gitHubApi.getUser(userId: "marty-suzuki")~ .subscribe( onSuccess: { (user: User) in ... }, onError: { (error: Error) in ... } ) disposable.dispose()SuspendWrapper以外の解決方法
本投稿では関数ごとに通信のリクエストを定義する形で実装していますが、APIKitのようにRequestオブジェクトベースで通信のリクエストを定義し、リクエストの実行はsend関数に一本化するという方法も試してみました。
リクエストの実行をsend関数に一本化することで、Kotlin向けのsend関数とSwift向けのSend関数に分けるだけで済むようになります。ApiClient.ktinterface ApiClient { suspend fun <RES> send(request: ApiRequest<RES>): RES fun <RES> send(request: ApiRequest<RES>, onSuccess: (RES) -> Unit, onThrow: (Throwable) -> Unit): Cancelable { val job = CoroutineScope(Dispatchers.Main).launch { try { onSuccess(send(request)) } catch (exception: Throwable) { onThrow(exception) } } return object: Cancelable { override fun cancel() { job.cancel() } } } }SuspendWrapperの場合と同様に、Singleへの変換ができます。
ApiClientType.swiftenum KotlinError: Error { case castError(Any?) case throwable(KotlinThrowable) } extension ApiClient { func send<T>(_ request: ApiRequest<T>) -> Single<T> { Single.create { observer in let cancelable = self.send( request: request as! ApiRequest<AnyObject>, onSuccess: { res in guard let response = res as? T else { observer(.failure(KotlinError.castError(res))) return } observer(.success(response)) }, onThrow: { observer(.failure(KotlinError.throwable($0))) } ) return Disposables.create { cancelable.cancel() } } } }最後に
Swiftからsuspend funをキャンセルすることができるようになりました。
しかし、Kotlinからはsuspend funとして定義すれば良いInterfaceを一旦SuspendWrapperを挟んでsuspend()
を呼び出す形になってしまうというデメリットもあります。
両プラットフォームにとって最善な落とし所を見つけていくことが重要になってくると思うので、引き続きいろいろと試していきたいと思います。
- 投稿日:2021-01-23T06:37:48+09:00
マイクなどの使用許可が得られなかった時のことを考慮した話
はじめに
アプリの中でマイクやカメラを使うときは、「マイクの使用を許可しますか?」といったアラートが表示されます。そこでユーザーが誤って「許可しない」を選択した場合、ユーザーにそのことを伝えるにはどうすれば良いでしょうか。ここではその解決策を共有します。
流れ
初めて許可を求める -> アラートを表示
許可されている場合 -> 何もアラートを表示させない
許可されていない場合 -> そのことをアラートで表示させる
といった処理をやっていきます。
ボタンが押された時
ここでは画面表示を担当するクラスでの動きを書いています。
@objc func startButtonTapped() { let status = AVCaptureDevice.authorizationStatus(for: AVMediaType.audio) if status == AVAuthorizationStatus.authorized { startButton.isHidden = true pauseButton.isHidden = false } else if status == AVAuthorizationStatus.restricted { audioRequestDelegate?.showPermissionChangeAlert() } else if status == AVAuthorizationStatus.notDetermined { audioRequestDelegate?.requestPermission() } else if status == AVAuthorizationStatus.denied { audioRequestDelegate?.showPermissionChangeAlert() } }上の関数は収録ボタンが押された時の動きを表す関数です。
AVCaptureDevice.authorizationStatus(for: AVMediaType.audio)
でマイクの使用許可に関する値を判定しています。その後、値に応じて処理を分けています。なお
AVAuthorizationStatus
は列挙型でpublic enum AVAuthorizationStatus : Int { case notDetermined = 0 case restricted = 1 case denied = 2 case authorized = 3 }と定義されているのですが、
case restricted = 1
はほとんどとる値ではないことがリファレンスに書かれているので、その値を取ることを想定していません。ボタンが押された時のコントローラーの動き
上のビュークラスから処理を委譲されたコントローラークラスの動きです。
extension RecordingViewController: AudioRequestDelegate{ //許可を求めるアラートを出す func requestPermission() { recordingModel?.requestRecord() } //拒否されているので、設定し直すアラートを出す func showPermissionChangeAlert() { print("拒否されてます") let alert = UIAlertController(title: "マイクの許可", message: "設定からマイクの使用を許可してください", preferredStyle: .alert) let alertAction: UIAlertAction = UIAlertAction(title: "OK", style: UIAlertAction.Style.default) alert.addAction(alertAction) present(alert, animated: true, completion: nil) } //許可されている時 func authorized() { recordingModel?.start() } }許可をされている時は録音をスタートし、まだ聞いていない時は確認アラートが表示されます。そして拒否されている時は
UIAlertController
を作成し、アラートでユーザに設定を変えることを伝えます。
許可が既に得られている場合とまだ許可を求めたことがない場合の処理はモデルクラスで定義したものを持ってきています。