20210123のSwiftに関する記事は7件です。

[Swift]UISliderクラス

Sliderで透過率を変更する

UISliderクラスを実装することで,理解していきます。

準備

以下の画像をAssetsフォルダに入れておきます。
ダウンロード.jpeg

実装

+ボタン(Library)→showtheimagelibraryからflowerを選択して、ViewControllerに配置します

スクリーンショット 2021-01-23 23.22.43.png
UISliderをViewControllerに接続する時に、ConnectionをActionに変更して、TypeをUISliderに変更します。
スクリーンショット 2021-01-23 23.30.05.png
以下のように、コードを書いて実行すると写真のalphaを変更することができます。

ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var flower: UIImageView!

    @IBAction func UISlider(_ sender: UISlider) {
        flower.alpha=CGFloat(sender.value)
    }


    override func viewDidLoad() {
        super.viewDidLoad()

    }
}


スライダーを自由に動かして写真のalphaを変更することができました。
スクリーンショット 2021-01-23 23.34.20.png

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

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から離れるほど数値の間隔が広くなる(分解能が落ちる)」ということも気をつける必要がありそうです。

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

AVFoundation 音再生

音再生方法

まず AVFoundation をインポート

viewController.swift
import UIKit
import AVFoundation

class ViewController: UIViewController,AVAudioPlayerDelegate {

    var player :AVAudioPlayer!

まず、音ファイルをXcodeに追加。追加するのはここ!!
今回は 
umbrella と souziki が入っていますね。
スクリーンショット 2021-01-23 21.07.44.png

音声を呼び出す用の関数を定義!

viewController.swift
    public 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.swift
    override func viewDidLoad() {
        super.viewDidLoad()

        prepareSound()

    }

あとは、ボタンなど、呼び出したいところで下記のオードを書けば

viewController.swift
    @IBAction func button(_ sender: Any) {

        if ((player?.isPlaying) != nil) {
            player?.stop()
            player?.currentTime = 0
        }
        player?.play()
    }

現在、選択私の違いによって出る音の変わる仕組みを製作中。

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

Swift Touchイベント処理でお絵かきアプリを作ってみました

Touchイベントの勉強をきっかけにこちらの記事を見て作ってみました。
記事に書かれている主機能の他に消しゴム、ペン/消しゴムのサイズの変更を追加しました。

Simulator Screen Shot - iPhone 8 Plus - 2021-01-23 at 20.39.29.png

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

【Swift】UITextViewで文字数制限と行数制限を行う方法

はじめに

クイズアプリを作成する上でTextViewを使用したので、
その時に学んだことを備忘録がてら共有できたらなと思います。

環境
・Swift version 5.3
・XCode version 12.3

完成形

gitや画像を載せようと思ったのですが、月の上限を超していたため載せれませんでした・・・。
申し訳ございませんが文字のみになってしまいます。

完成形の機能としては下記のような機能が実装されています。

・プレースホルダー
 TextFieldのように簡単にプレースホルダーを定義できないTextViewですが、
 プレースホルダーの実装をしてみました。
・文字数制限と文字数カウント
 今回は50文字とういう制限で実装しました。
・行数制限
 今回は7行という制限で実装しました。
・キーボードの非表示
 UITapGestureRecognizerを使い実装しました。

コード

全てコピペしてもうまく実行されません。
@IBOutletやその変数に関する箇所はご自身の内容にしたがってください。

ViewController.swift
import 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)
}

さいごに

画像などが無い状態での説明でしたのでわかりにくかったらすみません。

そんなに難しいことは行っていないので、
別の方の記事も参考にしながら実装してみてください!

以上、最後までご覧いただきありがとうございました。

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

【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.kt
interface 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.kt
internal 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.kt
class 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.kt
interface 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.swift
final 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.kt
class 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.kt
interface 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.swift
enum 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()を呼び出す形になってしまうというデメリットもあります。
両プラットフォームにとって最善な落とし所を見つけていくことが重要になってくると思うので、引き続きいろいろと試していきたいと思います。

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

マイクなどの使用許可が得られなかった時のことを考慮した話

はじめに

 アプリの中でマイクやカメラを使うときは、「マイクの使用を許可しますか?」といったアラートが表示されます。そこでユーザーが誤って「許可しない」を選択した場合、ユーザーにそのことを伝えるにはどうすれば良いでしょうか。ここではその解決策を共有します。

流れ

ezgif-2-11aa8ab89fc0.gif

初めて許可を求める -> アラートを表示
許可されている場合 -> 何もアラートを表示させない
許可されていない場合 -> そのことをアラートで表示させる
といった処理をやっていきます。

 

ボタンが押された時

ここでは画面表示を担当するクラスでの動きを書いています。

@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を作成し、アラートでユーザに設定を変えることを伝えます。
許可が既に得られている場合とまだ許可を求めたことがない場合の処理はモデルクラスで定義したものを持ってきています。

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