20200916のiOSに関する記事は12件です。

【Swift】iOSで取得できる位置情報を実測してみた

iOSでの取得できる位置情報がどの程度正確かが気になったので、実測してみた。

前提知識

iOSにおいて、位置情報の精度は以下の6パターンを設定することが可能。

No. 補足
1 kCLLocationAccuracyBestForNavigation ナビアプリなどで利用できる最高精度
2 kCLLocationAccuracyBest 最高精度(デフォルト値)
3 kCLLocationAccuracyNearestTenMeters 誤差10m程度の精度
4 kCLLocationAccuracyHundredMeters 誤差100m程度の精度
5 kCLLocationAccuracyKilometer 誤差1,000m程度の精度
6 kCLLocationAccuracyThreeKilometers 誤差3,000m程度の精度

参考:CLLocationAccuracy

測定方法

下図のルート(A地点からB地点)を歩いてみて、OSが取得する位置情報の経度/緯度をプロットしてみる。
OSが取得する位置情報とは、locationManager(_:didUpdateLocations:) で取得できる位置情報のこと。

image.png

実測結果

誤差10m 誤差100m 誤差1,000m
image.png image.png image.png
平均して1分間に約60回位置情報を取得。
移動に8分15秒(495秒)かかり、その間に500回、位置情報更新メソッドが呼ばれた。
平均して1分間に約10回位置情報を取得。
移動に8分45秒(525秒)かかり、その間に90回、位置情報更新メソッドが呼ばれた。
平均して1分間に約5回位置情報を取得。
移動に8分36秒(516秒)かかり、その間に45回、位置情報更新メソッドが呼ばれた。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ニュースアプリにコロナ関連の記事があったら、リジェクトされた。

1.リジェクトされた理由

ニュースアプリのAppStoreに申請をしたのですが、
コロナ関連の記事があったので、リジェクトされました。

2.対策

コロナ関連の記事は削除しました。

3.注意点

今回は外部データをいじるだけだったので、新たにビルドする必要なかったら、
再申請では無く、問題解決センターに返信をしました。
こっちの方が早いからね!
再申請だとまた後ろから、並び直しだしね!

4.宣伝

これがコロナ関連の記事が無くなってしまったニュースアプリです。
NewsTweet(ニューズツイート)
https://apps.apple.com/jp/app/newstweet-ニューズツイート/id1531315934

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

iOS14GMでデフォルトブラウザを変更した時のcanOpenURLがおかしいから整理してみた

何が起こっている?

iOS14 PublicBeta8以降を入れたiOS端末で、デフォルトブラウザを変更した際にブラウザ遷移が行えない。

発生条件

  • iOS14PB版、もしくはGM版、正式版(iOS14.0)
  • デフォルトブラウザをsafari以外に設定していること

デフォルトブラウザ設定方法

  1. 「設定」アプリ起動
  2. デフォルトブラウザにしたいアプリへ移動(例:Chrome,Microsoft Edge)
  3. 「デフォルトブラウザApp」を開く
  4. 標準ブラウザをSafariからChromeに変更する

原因

UIApplicationのcanOpenURL メソッドがfalseを返却している。
これが、iOS14の仕様なのかバグなのかは現時点(日本時間2020/09/16)では不明。
canOpenURL returns false when I changed the default browser on iOS 14

そもそもcanOpenURLとは?

Determine whether there is an installed app registered to handle a URL scheme (canOpenURL(_:))
(URLスキームを処理するために登録されたインストール済みアプリがあるかどうかを判別する)

アプリから別のアプリを起動する(URLスキーム)際に、処理するためのアプリが端末に存在しているかを返す関数。
(そもそもここの認識が間違えていて、「全てのURL起動にcanOpenURLを通す」という誤用?が今回の事件を引き起こしたと思われる。私もその一人です)

対応方法

その1:canOpenURLを通さない

そもそもiOS端末にはブラウザアプリとしてSafariが必ず入っている。
かつ長押ししてもsafariアプリは削除もできないので、
ブラウザ起動をしようとして、iOS端末にブラウザアプリがないことは有り得ない はず。
(とはいえ、ブラウザ起動でcanOpenURLがfalseを返すことはないはずが、今回起こっているわけですが)

上記の通り、canOpenURLの目的は別のアプリの起動ができるかを確認するためのもののため、
ブラウザ起動のためのURL起動の場合は、canOpenURLを通す必要はなさそう。

よっていきなりopenを叩くように修正する。

// AS-IS例
if UIApplication.shared.canOpenURL(URL(string: "https://qiita.com/")!) {
      UIApplication.shared.open(URL(string: "https://qiita.com/")!)
} else {
      print("ブラウザ起動失敗")
}
// To-BE例
UIApplication.shared.open(URL(string: "https://qiita.com/")!)

その2:plistにhttp/httpsを追加する

canOpenURLの判定には、plistの設定が反映されるためブラウザ起動のURLを許可する。

    <key>LSApplicationQueriesSchemes</key>
    <array>
        <string>http</string>
        <string>https</string>
    </array>

参考

公式:
UIApplication | Apple Developer Documentation(引用はこちらより)
canOpenURL(_:) | Apple Developer Documentation

参考:
特定のアプリがインストール済みかチェックする
[iOS] ディープリンク(Custom URL Scheme)でアプリを起動する


免責:全てのiOS14のアプリの動作を保証する解決方法ではございません
間違っている点や他の方法があれば教えてください!

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

【iOS14】デフォルトブラウザを変更した時にcanOpenURLがfalseになる問題

何が起こっている?

iOS14 PublicBeta8以降を入れたiOS端末で、デフォルトブラウザを変更した際にブラウザ遷移が行えない。

発生条件

  • iOS14PB版、もしくはGM版、正式版(iOS14.0)
  • デフォルトブラウザをsafari以外に設定していること

デフォルトブラウザ設定方法

  1. 「設定」アプリ起動
  2. デフォルトブラウザにしたいアプリへ移動(例:Chrome,Microsoft Edge)
  3. 「デフォルトブラウザApp」を開く
  4. 標準ブラウザをSafariからChromeに変更する

原因

UIApplicationのcanOpenURL メソッドがfalseを返却している。
これが、iOS14の仕様なのかバグなのかは現時点(日本時間2020/09/16)では不明。
canOpenURL returns false when I changed the default browser on iOS 14

そもそもcanOpenURLとは?

Determine whether there is an installed app registered to handle a URL scheme (canOpenURL(_:))
(URLスキームを処理するために登録されたインストール済みアプリがあるかどうかを判別する)

アプリから別のアプリを起動する(URLスキーム)際に、処理するためのアプリが端末に存在しているかを返す関数。
(そもそもここの認識が間違えていて、「全てのURL起動にcanOpenURLを通す」という誤用?が今回の事件を引き起こしたと思われる。私もその一人です)

対応方法

その1:canOpenURLを通さない

そもそもiOS端末にはブラウザアプリとしてSafariが必ず入っている。
かつ長押ししてもsafariアプリは削除もできないので、
ブラウザ起動をしようとして、iOS端末にブラウザアプリがないことは有り得ない はず。
(とはいえ、ブラウザ起動でcanOpenURLがfalseを返すことはないはずが、今回起こっているわけですが)

上記の通り、canOpenURLの目的は別のアプリの起動ができるかを確認するためのもののため、
ブラウザ起動のためのURL起動の場合は、canOpenURLを通す必要はなさそう。

よっていきなりopenを叩くように修正する。
これでデフォルトブラウザを変更していても、ちゃんとsafariではないブラウザアプリで起動される。

// AS-IS例
if UIApplication.shared.canOpenURL(URL(string: "https://qiita.com/")!) {
      UIApplication.shared.open(URL(string: "https://qiita.com/")!)
} else {
      print("ブラウザ起動失敗")
}
// To-BE例
UIApplication.shared.open(URL(string: "https://qiita.com/")!)

その2:plistにhttp/httpsを追加する

canOpenURLの判定には、plistの設定が反映されるためブラウザ起動のURLを許可する。

    <key>LSApplicationQueriesSchemes</key>
    <array>
        <string>http</string>
        <string>https</string>
    </array>

参考

公式:
UIApplication | Apple Developer Documentation(引用はこちらより)
canOpenURL(_:) | Apple Developer Documentation

参考:
特定のアプリがインストール済みかチェックする
[iOS] ディープリンク(Custom URL Scheme)でアプリを起動する


間違っている点や他の方法があれば教えてください!
iOS14の発表でバタバタしている同志のiOSエンジニアのみなさま、お互いに本当にお疲れ様です。

免責:全てのiOS14のアプリの動作を保証する解決方法ではございません

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

iOSの「ショートカット」アプリ(旧Workflow)で、無限ループさせる方法

✏️この記事について

ショートカットアプリを作成している際に、「無限ループ」の方法を見つけたため、そのメモを備忘録がわりに書きます

参考にしたのはここここのサイト

✨教訓👉「無限ループ」は存在します!

iOSのショートカットアプリ、便利で使っていたのですが、唯一for文による無限ループが見当たらないことに不満を持っていました。(「参考」を参考にしてください)

🤔

でも、どうやら、ショートカットアプリで、「無限ループ」を実現する術があるようです。

無限ループを作成する

「ショートカット」にはアクションのグループを永遠に繰り返すアクションは用意されていませんが、「ショートカットを実行」アクションを使用することでこの動作をエミュレートできます。「ショートカットを実行」アクションを使用すると、ショートカット内で別のショーカットを実行できます。「ショートカットを実行」アクションで内包されている同じショートカットを実行するように構成すると、ショートカットは停止するまで最初から最後まで繰り返し実行されます。

「ショートカット」で「繰り返す」アクションを使用する - Apple サポート

ふむ...「ショートカットの実行」を、ショートカット内に埋め込めばいいんですね
1A52E8BD-E2AB-4580-AD77-6EE2BB6FED99.gif

できた...😇
ショートカットアプリの幅が広がりそうですね😭

参考

👇作ったレシピ
FE46C51E-4C5D-4E7F-83FD-166E59FACAC0.jpeg
👇「繰り返す」アクション
B2816C80-2213-4768-9B0A-85976F5AAAA6.jpeg
👇➕か、➖ボタンでしか制御できない...
97DD420D-D271-4F83-B912-C576DF34B2D6.jpeg
👇「各項目を繰り返す」アクション
2C340DED-D88E-4788-9AB9-E5147230643B.jpeg
👇改行された文章の行数を数えるなど、基礎的な制御ができる
1A75F63A-63B0-422F-83AD-964665AD3354.jpeg

参考サイト様

「ショートカット」で「繰り返す」アクションを使用する - Apple サポート

「if文」と「繰り返し」を使ってショートカットレシピを作成する。(iOS13 ショートカット)

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

XamarinのVisualStudio for Macで実機ビルドができなくなった時の対処の備忘録

参考

Xamarinで作成したiOSアプリが実機デバッグ起動できなくなった場合の対処方法

Visual Studio For MacでのiOS実機デバッグについて

対処

  • クリーンしてビルド
  • Visual Studio for Macの再起動
  • Xcodeの再起動
  • iPhoneの再起動
  • Developer CenterにてDeviceの登録
  • APP idのプロビジョニングプロファイルの作成

今回は「Visual Studio for Macの再起動」で解決。

前回は開発環境に入ってるアプリを削除したら解決したり、Macの環境はわからない。
iPhone7のデバッグの時なんかは同じエラーでiPhone側の空き容量が足りなくて2時間も悩まされた。

日本一やさしいMacの手本とか存在しないかしら。

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

【iOS14】UNUserNotificationCenterでSwitch must be exhaustiveのwarning対応

Xcode12(GM)でビルドするとSwitch must be exhaustiveのwarningが👀
実装を確認すると、通知許可ステータスを確認するコードで吐き出されている様子

        UNUserNotificationCenter.current().getNotificationSettings { settings in
            switch settings.authorizationStatus {
            case .authorized, .provisional:
                print("許可")
            case .denied:
                print("拒否")
            case .notDetermined:
                print("許可求む")
            @unknown default:
                fatalError()
            }

UNAuthorizationStatusのリファレンスを覗くとiOS14から新たに
.ephemeral
が追加されていました。
https://developer.apple.com/documentation/usernotifications/unauthorizationstatus

この子は
The application is temporarily authorized to post notifications. Only available to app clips.
つまり、iOS14から実装可能なApp Clipsにおいて、一時的に通知の許可を取ることができ、そのステータスということです。

App Clipsを実装している場合の実装はこちらの公式リファレンスを参考に
https://developer.apple.com/documentation/app_clips/enabling_notifications_in_app_clips

let center = UNUserNotificationCenter.current()
center.getNotificationSettings { (settings) in {
    if settings.authorizationStatus == .ephemeral {

        // The user didn't disable notifications in the app clip card.
        // Add code for scheduling or receiving notifications here.
        return
    }
}

などを追加します。

App Clipsを実装していない場合は、通らない想定なので

        UNUserNotificationCenter.current().getNotificationSettings { settings in
            switch settings.authorizationStatus {
            case .authorized, .provisional:
                print("許可")
            case .denied:
                print("拒否")
            case .notDetermined:
                print("許可求む")
            case .ephemeral:
                fatalError()// or assertionFailure()
            @unknown default:
                fatalError()
            }

でwarning回避できます。
リファレンスにbetaと書かれているし、fatalError利用したくない方は念の為
assertionFailureで、取得失敗の扱いで良いかと思います🙋‍♂️

ご参考までに🐣🌱

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

ライブラリを使わずに美しいカラーピッカーを実装【iOSアプリ】【Swift】

経緯

お絵かきアプリにカラーピッカーを付けたかったのでいろいろ探したのですが、ライブラリを使ったのしか出てこなかった。
...
...
じゃあ自力で1から作っちゃおう!

環境

・macOS Catalina
・Xcode 11.7
・Swift 5

Assetsに画像を追加

・千鳥柄の画像 × 2枚
※なくても良い。(alphaが1より低いときにわかりやすくするため)
1枚目 -> サイズ:220 × 37.5 、名前:chidori_s
2枚目 -> サイズ:290 × 290 、名前:chidori
・Hueスライダー用の画像
サイズ:300×13、名前:colors
画像は私のブログ記事から入手できます。

全コード

ボタンを押すとColor Pickerが現れます。

import UIKit

class ViewController: UIViewController {

    // 現在表示されているSlider
    var sliderNow = ""
    // Hue一時保存用
    var hue: CGFloat!
    // Color Picker を表示するボタン
    var colorBtnNow: UIButton!

    // Color Picker
    var picker: UIView!
    var sliderPallete: UIView!
    // Color Pickerの上の白丸
    let thumb = UIView()
    // 変更前、現在の色プレビュー
    var twoColors = [UIButton(), UIButton()]
    // パレットのグラデーションレイヤー
    var gradLayer = [CAGradientLayer(), CAGradientLayer()]
    // Slider
    var colorSlider = [UISlider(), UISlider(), UISlider(), UISlider()]
    // Slider の色
    let sliderGrad = [CAGradientLayer(), CAGradientLayer(), CAGradientLayer(), CAGradientLayer()]
    // 現在のSliderの値
    var numText = [UILabel(), UILabel(), UILabel(), UILabel()]

    // 現在の色
    var colorNow: UIColor!
    // 現在のrgba/hsba
    var changingColor = [CGFloat(), CGFloat(), CGFloat(), CGFloat()]

    override func viewDidLoad() {
        super.viewDidLoad()

        let b = UIButton()
        b.frame = CGRect(x: 10, y: 60, width: 80, height: 50)
        b.backgroundColor = .systemOrange
        b.setTitle("button", for: .normal)
        view.addSubview(b)
        b.addTarget(self, action: #selector(showColorPicker(sender:)), for: .touchUpInside)
    }

    @objc func showColorPicker(sender: UIButton) {

        picker = UIView()
        picker.backgroundColor = UIColor(white: 0.2, alpha: 1)
        picker.layer.cornerRadius = 10
        picker.frame = CGRect(x: 60, y: 120, width: 310, height: 615)
        view.addSubview(picker)

        colorBtnNow = sender

        let parameters = ["RGB", "HLS"]
        let seg = UISegmentedControl(items: parameters)
        seg.frame = CGRect(x: 95, y: 20, width: 120, height: 35)
        seg.backgroundColor = .gray
        seg.selectedSegmentIndex = 0
        seg.addTarget(self, action: #selector(segmentChanged), for: .valueChanged)
        picker.addSubview(seg)

        // 色プレビュー
        let colorView = UIImageView()
        colorView.image = UIImage(named: "chidori_s")
        colorView.frame = CGRect(x: 45, y: 70, width: 220, height: 37.5)
        colorView.clipsToBounds = true
        colorView.layer.cornerRadius = 6
        colorView.isUserInteractionEnabled = true
        picker.addSubview(colorView)

        var x: CGFloat = 0
        for c in twoColors {
            c.frame = CGRect(x: x, y: 0, width: 110, height: colorView.frame.height)
            c.backgroundColor = sender.backgroundColor!
            colorView.addSubview(c)
            c.addTarget(self, action: #selector(setColor), for: .touchDown)
            x = 110
        }
        // グラデーションパレット
        let hsb = sender.backgroundColor!.hsb()
        let gradView = UIButton(frame: CGRect(x: 10, y: 120, width: 290, height: 290))
        gradView.addTarget(self, action: #selector(colorGradLayer_tapped), for: .allTouchEvents)
        gradView.setBackgroundImage(UIImage(named: "chidori"), for: .normal)

        hue = hsb[0]
        func set_gradLayer(_ g: CAGradientLayer, colors: [CGColor], start: CGPoint) {
            g.colors = colors
            g.startPoint = start
            g.endPoint = CGPoint(x: 0.0, y: 0.0)
            g.frame = CGRect(x: 0, y: 0, width: 290, height: 290)
            g.locations = [0, 1]
            gradView.layer.addSublayer(g)
        }
        set_gradLayer(gradLayer[0],
                      colors: [hsb_color([hsb[0], 1, 1, hsb[3]]).cgColor,
                               UIColor(white: 1, alpha: hsb[3]).cgColor],
                      start: CGPoint(x: 1.0, y: 0.0))
        set_gradLayer(gradLayer[1],
                      colors: [UIColor.black.cgColor, UIColor.clear.cgColor],
                      start: CGPoint(x: 0.0, y: 1.0))

        thumb.center = CGPoint(x: 290*hsb[1], y: 290*(1-hsb[2]))
        thumb.frame.size = CGSize(width: 24, height: 24)
        thumb.layer.cornerRadius = 12
        thumb.backgroundColor = .white
        thumb.isUserInteractionEnabled = false
        gradView.addSubview(thumb)
        picker.addSubview(gradView)

        setUpRGBView()
    }

    @objc func colorGradLayer_tapped(sender: UIButton, event: UIEvent) {
        if let touch = event.touches(for: sender)?.first {
            // タップした場所を取得
            let loc = touch.location(in: sender)

            if 0..<290 ~= loc.x { thumb.center.x = loc.x }
            if 0..<290 ~= loc.y { thumb.center.y = loc.y }

            changingColor[0] = hue
            changingColor[1] = thumb.center.x/290
            changingColor[2] = 1 - thumb.center.y/290

            if sliderNow == "hls" {
                for i in 1...2 { updateSlider(tag: i, value: changingColor[i]) }
            } else {
                changingColor = hsb_color(changingColor).rgb()
                for i in 0...2 { updateSlider(tag: i, value: changingColor[i]) }
            }
            changeColor()
        }
    }

    @objc func segmentChanged(_ segment: UISegmentedControl) {
        switch segment.selectedSegmentIndex {
        case 0: setUpRGBView()
        case 1: setUpHLSView()
        default: break
        }
    }

    @objc func setColor(sender: UIButton) {
        if sliderNow == "hls" { changingColor = sender.backgroundColor!.hsb()
        } else { changingColor = sender.backgroundColor!.rgb() }
        changeColor()
    }

    @objc func setUpHLSView() {
        sliderNow = "hls"
        setSlider(colors: colorBtnNow.backgroundColor!.hsb(), text: ["colors", "satu", "br", "alpha"])
        colorSlider[0].setMinimumTrackImage(UIImage(named: "colors"), for: .normal)
        colorSlider[0].setMaximumTrackImage(UIImage(named: "colors"), for: .normal)
        for s in colorSlider[0].layer.sublayers! { s.removeFromSuperlayer() }
        set_grad_hls(1...3)
    }
    func set_grad_hls(_ range: ClosedRange<Int>) {
        for i in range {
            var c = changingColor
            if i != 3 { c[3] = 1 }
            c[i] = 0
            let color1 = UIColor(hue: c[0], saturation: c[1], brightness: c[2], alpha: c[3])
            c[i] = 1
            let color2 = UIColor(hue: c[0], saturation: c[1], brightness: c[2], alpha: c[3])
            sliderGrad[i].colors = [color1.cgColor, color2.cgColor]
        }
    }

    @objc func setUpRGBView() {
        sliderNow = "rgb"
        let color = colorBtnNow.backgroundColor!
        setSlider(colors: color.rgb(), text: ["red", "green", "blue", "alpha"])
        set_grad_rbg(0...3)
    }
    func set_grad_rbg(_ range: ClosedRange<Int>) {
        for i in range {
            var c = changingColor
            if i != 3 { c[3] = 1 }
            c[i] = 0
            let color1 = UIColor(red: c[0], green: c[1], blue: c[2], alpha: c[3])
            c[i] = 1
            let color2 = UIColor(red: c[0], green: c[1], blue: c[2], alpha: c[3])
            sliderGrad[i].colors = [color1.cgColor, color2.cgColor]
        }
    }

    func setSlider(colors: [CGFloat], text: [String]) {
        sliderPallete?.removeFromSuperview()
        sliderPallete = UIView(frame: CGRect(x: 0, y: 410, width: 310, height: 250))
        picker.addSubview(sliderPallete)
        for i in 0..<colors.count {
            colorSlider[i] = slider(tag: i, img: text[i], value: colors[i], superV: sliderPallete)
            colorSlider[i].addTarget(self, action: #selector(slider_value_changed), for: .valueChanged)
            changingColor[i] = colors[i]
        }
        for i in 0..<sliderGrad.count {
            sliderGrad[i].frame = CGRect(x: 0, y: 0, width: 220, height: 20)
            sliderGrad[i].startPoint = CGPoint(x: 0.0, y: 0.0)
            sliderGrad[i].endPoint = CGPoint(x: 1.0, y: 0.0)
            sliderGrad[i].locations = [0, 1]
            sliderGrad[i].cornerRadius = 10
            colorSlider[i].layer.addSublayer(sliderGrad[i])
        }
    }
    func slider(tag: Int, img: String, value: CGFloat, superV: UIView) -> UISlider {
        numText[tag].backgroundColor = UIColor.clear
        numText[tag].font = .systemFont(ofSize: 16, weight: UIFont.Weight(6))
        numText[tag].textColor = .white
        superV.addSubview(numText[tag])

        let s = UISlider()
        s.tag = tag
        s.tintColor = .clear
        s.frame = CGRect(x: 65, y: 25+tag*40, width: 220, height: 20)
        superV.addSubview(s)
        if img == "alpha" {
            numText[tag].text = String(format: "%.1f", value)
        } else { numText[tag].text = "\(Int(value*255))"}
        s.maximumValue = 0
        s.maximumValue = 1
        s.setValue(Float(value), animated: false)
        numText[tag].frame = CGRect(x: 14, y: s.frame.origin.y-9, width: 60, height: 35)
        return s
    }

    @objc func slider_value_changed(slider: UISlider) {
        updateSlider(tag: slider.tag, value: CGFloat(slider.value))
        changeColor()
        changingColor[slider.tag] = CGFloat(slider.value)
        let c = changingColor
        // update grad layer
        if sliderNow == "hls" {
            hue = c[0]
            switch slider.tag {
            case 0, 3:
                gradLayer[0].colors = [hsb_color(c).cgColor, UIColor(white: 1, alpha: c[3]).cgColor]
                set_grad_hls(1...3)
            case 1: thumb.center.x = 290*c[1]; break
            case 2: thumb.center.y = 290*(1-c[2]); break
            default: break
            }
        } else {
            let hsb = rgb_color(c).hsb()
            hue = hsb[0]
            thumb.center = CGPoint(x: 290*hsb[1], y: 290*(1-hsb[2]))
            gradLayer[0].colors = [hsb_color([hsb[0], 1, 1, hsb[3]]).cgColor,
                                   UIColor(white: 1, alpha: hsb[3]).cgColor]
        }
    }

    func updateSlider(tag: Int, value: CGFloat) {

        colorSlider[tag].setValue(Float(value), animated: false)
        if tag == 3 { numText[tag].text = String(format: "%.1f", value) }
        else { numText[tag].text = "\(Int(value*255))" }
    }

    func changeColor() {
        var color = rgb_color(changingColor)
        if sliderNow == "hls" { color = hsb_color(changingColor) }
        else { set_grad_rbg(0...3) }
        colorBtnNow.backgroundColor = color
        colorNow = color
        twoColors[1].backgroundColor = color
    }


    func hsb_color(_ c: [CGFloat]) -> UIColor {
        return UIColor(hue: c[0], saturation: c[1], brightness: c[2], alpha: c[3])
    }
    func rgb_color(_ c: [CGFloat]) -> UIColor {
        return UIColor(red: c[0], green: c[1], blue: c[2], alpha: c[3])
    }
}

extension UIColor {
    func hsb() -> [CGFloat] {
        var (h, s, b, a) = (CGFloat(), CGFloat(), CGFloat(), CGFloat())
        getHue(&h, saturation: &s, brightness: &b, alpha: &a)
        return [h, s, b, a]
    }
    func rgb() -> [CGFloat] {
        var (r, g, b, a) = (CGFloat(), CGFloat(), CGFloat(), CGFloat())
        getRed(&r, green: &g, blue: &b, alpha: &a)
        return [r, g, b, a]
    }
}


実装例

スクリーンショット 0002-09-16 午前11.41.50.png

まとめ

スライダーの色を動的に変えることで
直感的に操作できるUIになりましたね!

もっと詳しく

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

DZNEmptyDataSetを使わずにEmptyStateを実装する

DZNEmptyDataSetは2014年6月(執筆時点で6年前)からある、EmptyStateを表示するためのライブラリで、今も現役で利用されている素晴らしいライブラリです。

ですが、「ちょっとEmptyStateの表示したいだけなのにライブラリを入れるほどでもないし...」とか「SPMに寄せたいけど、DZNEmptydataSetはSPM対応のPRまだマージしてなくて、stableだとCocoaPodsかCarthageしか使えないし...」とか「Method Swizzlingが怖い...」などなど、自作を試みたい動機がいくつかあると思います。

そこで、簡単に自作する方法を紹介したいと思います。SwiftUIではifで切り分けて表示するだけで、非常に簡単なため今回は扱いません。

実装 Delegate

まず、このようにEmptyStateで表示したいラベルやViewを用意します。今回はラベルのみです。

YourViewController.swift
    let emptyLabel: UILabel = {
        let label = UILabel(frame: .zero)
        label.font = UIFont.preferredFont(forTextStyle: .title1)
        label.textColor = UIColor.secondaryLabel
        label.textAlignment = .center
        label.text = "Text that you want to display"
        label.isUserInteractionEnabled = false
        return label
    }()

そのEmptyState用のViewを、スクリーンの高さと等しいViewに対して追加します。その理由としては、ツールバーなどが下部に含まれていると画面中心より上部にEmptyStateのViewが表示されてしまい(centerが高さ分ズレるため)、ビジュアルに違和感が生じるためです。今回はnavigationController?.viewとしました。(ご自分の環境に合わせて適切にViewの選択をお願いします)

この実装ではViewDebuggerを見ると、emptyLabelが最前面に存在している形になります。

YourViewController.swift
navigationController?.view.addSubview(emptyLabel)
navigationController?.view.bringSubviewToFront(emptyLabel)

emptyLabel.equalToParent()

レイアウトを簡単にするために、いくつかextensionを追加しています。

Constraint.swift
extension UIView {

    @_functionBuilder
    public struct ConstrainsBuilder {
        static func buildBlock(_ constraints: NSLayoutConstraint...) -> [NSLayoutConstraint] {
            constraints
        }
    }

    public func makeConstraints(@ConstrainsBuilder builder: (UIView) -> [NSLayoutConstraint]) {
        translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate(builder(self))
    }

    /// - Precondition: The superView should not be nil.
    public func equalToParent() {
        precondition(superview != nil)

        self.makeConstraints {
            $0.leadingAnchor.constraint(equalTo: superview!.leadingAnchor)
            $0.trailingAnchor.constraint(equalTo: superview!.trailingAnchor)
            $0.topAnchor.constraint(equalTo: superview!.topAnchor)
            $0.bottomAnchor.constraint(equalTo: superview!.bottomAnchor)
        }

    }

}

あとはテーブルやコレクションのデータソースで出し分けをするのみです。今回はテーブルの実装を解説します。

データソースの変更の際に呼ばれるtableView(_:numberOfRowsInSection:) -> Intの内部で、その時返すデータ数に応じて、isHiddenの切り替えを行うと期待する挙動をします。

YourViewController+DataSource.swift
extension YourViewController {

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        let numberOfRows = yourItems.count ?? 0

        if numberOfRows == 0 {
            emptyLabel.isHidden = false
        } else {
            emptyLabel.isHidden = true
        }

        return numberOfRows
    }
}

まとめ

多少ソースコードに余分を含むことにはなりますが、ライブラリを使わなくてもデータ件数による切り替えを行うだけでEmptyStateの実現ができました。

PS

Combine

Publisherを使えば、実装もうちょい楽にできないかな?と思い以下のようにしてみたのですが、データを変更してみても一回しか値が流れてこなかったので諦めました。Combineはまだ素人のため、何か実現方法あれば教えていただけると助かります。

Publisher.swift
tableView.publisher
.receive(on: RunLoop.main)
.map({ $0.numberOfRows(inSection: 0) > 0 })
.assign(to: \.isHidden, on: emptyLabel)
.store(to: &cancellables)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UICollectionViewCompositionalLayoutでWaterfall(Pinterest風)レイアウトを実現する

iOS 13が登場して1年が経過しました。(もうiOS 14も出ますね)
昨年登場した物としてSwiftUIやCombineは注目度が高かったですがUICollectionViewCompositionalLayoutも忘れてはいけない存在です。
UICollectionViewCompositionalLayoutは簡単に柔軟なレイアウトを構築することができるので使い始めるとかなりの画面で役に立ちます。
しかし、少し凝ったレイアウトをしようと思うとどうすればいいのか分からなかったので、今回はPinterest風のレイアウトをどう実現するのかを考えてみました。

Waterfallレイアウト(Pinterest風レイアウト)とは

名称未設定.png

Waterfallレイアウトはこのように上からサイズが異なるコンポーネントが上から積み重なっているようなレイアウトのことを言います。
水が上から下に落ちるように見えるのでWaterfall(滝)ということですね。

レイアウトの考え方

レイアウトを作る際に重要なのは次の2点です。

  • カラム数(横に何列表示するか)
  • それぞれのセルのサイズが明確なこと

上記が分かっていればカラムごとに高さを足していけばそれっぽいレイアウトが作れます。

実装サンプル

protocol WaterfallLayoutDelegate: AnyObject {
    func numberOfColumns() -> Int
    func columnsSize(at indexPath: IndexPath) -> CGSize
    func columnSpace() -> CGFloat
}

lazy var collectionViewLayout: UICollectionViewCompositionalLayout = {
    return UICollectionViewCompositionalLayout { [unowned self] (section, environment) -> NSCollectionLayoutSection? in
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(environment.container.effectiveContentSize.height))
        let group = NSCollectionLayoutGroup.custom(layoutSize: groupSize) { [unowned self] (environment) -> [NSCollectionLayoutGroupCustomItem] in
            var items: [NSCollectionLayoutGroupCustomItem] = []
            var layouts: [Int: CGFloat] = [:]
            let space: CGFloat = self.waterfallLayout.flatMap({ CGFloat($0.columnSpace()) }) ?? 1.0
            let numberOfColumn: CGFloat = self.waterfallLayout.flatMap({ CGFloat($0.numberOfColumns()) }) ?? 2.0
            let defaultSize = CGSize(width: 100, height: 100)

            (0 ..< self.collectionView.numberOfItems(inSection: section)).forEach {
                let indexPath = IndexPath(item: $0, section: section)

                let size = self.waterfallLayout?.columnsSize(at: indexPath) ?? defaultSize
                let aspect = CGFloat(size.height) / CGFloat(size.width)

                let width = (environment.container.effectiveContentSize.width - (numberOfColumn - 1) * space) / numberOfColumn
                let height = width * aspect

                let currentColumn = $0 % Int(numberOfColumn)
                let y = layouts[currentColumn] ?? 0.0 + space
                let x = width * CGFloat(currentColumn) + space * (CGFloat(currentColumn) - 1.0)

                let frame = CGRect(x: x, y: y + space, width: width, height: height)
                let item = NSCollectionLayoutGroupCustomItem(frame: frame)
                items.append(item)

                layouts[currentColumn] = frame.maxY
            }
            return items
        }
        return NSCollectionLayoutSection(group: group)
    }
}()

大事なところだけ抜き出しておきました。
フルのサンプルコードは以下のGistに置いています。

fromkk/WaterfallLayout.swift

まとめ

NSCollectionLayoutGroup.customを利用することで柔軟なレイアウトも簡単に構築することができました。
NSCollectionLayoutGroupCustomItemframeの他にもzIndexを指定することができるので、色々と柔軟なレイアウトが作れそうですね。

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

FlutterでiOS、Android両方で動くカメラアプリを作る

Flutterを使ってマルチプラットフォームなカメラアプリを作っていく

「iOSでもAndroidでも動くアプリで、」
「スマホ内の画像にアクセスしてローカルのDBに保存可能」
「アプリ内でもカメラを起動できる」

っていう要件をFlutterで満たせるかの検証をバイト先に任されたからなー

Flutter

iOS、Androidといった異なるプラットフォームのアプリを一つのコードで書くことができるGoogle製のオープンソースのクロスプラットフォームのフレームワーク

公式ドキュメントも充実・サードパーティー製のプラグインも日々増えているので、旬な技術だと思っている

公式Youtubeにはお洒落な感じデザインのやらWidgetやらの説明動画がたくさんあるので楽しい

環境構築は以下のサイトを参考にした。ありがとうございました

ホットリロードが神すぎる

早速作っていきたいのだけど、Flutterは言語はdartでWidget単位でコードを完成させてアプリを作っていく

公式ドキュメントでもFlutterではWidgetが全てだよって言ってる

In Flutter, “everything is a widget”! - Flutter documentation

特に重要なWidgetが Stateless WidgetStatefull Widget の2つで順番におさらいしていく

そのあと、画像ファイル・カメラへのアクセスを可能にするプラグインであるimage_picker と、データベースを扱うためのプラグインsqfliteの紹介に入って、最後にコードを説明して終わり

Stateless Widget

名前の通り状態を持たないWidget。

以下は公式YouTubeで取り上げられているコード例

class ItemCount extends StatelessWidget {
 final String name;
 final int count;

 ItemCount({this.name, this.count});

 @overide
 Widget build(BuildContext context) {
  return Text('$name: $count')
 }
}

String型のnameとint型のcountを与えると、それらをTextWidgetに渡して表示させるようなWidgetである。

Statefull Widget

状態をもつWidgetで、Stateが更新されたときに内容が更新される。

先ほどのコードをStatefulにすると以下のような感じになる。

class ItemCount extends StatefulWidget {
 final String name;

 ItemCounter({this.name});

 @override
 _ItemCounterState createState() => _ItemCounterState();
}

class _ItemCounterState extends State<ItemCounter> {
 int count = 0;

 @overide
 Widget build(BuildContext context) {
   return GestureDetector(
     onTap: () {
       setState(() {
         count++;
       });
     ),
     child: Text('${widget.name}: $count'),
   );
 }
}

setStateはその中に書かれた変数が変化した時にクラスに変更を伝える役割を持っている

これでStatelessだった画面が、タップするとcountが上がっていくプログラムになった

image_picker

image_pickerははアプリ内でカメラを立ち上げたり、スマホ内のメディア(画像)にアクセスさせたりするのに使うプラグイン

A Flutter plugin for iOS and Android for picking images from the image library, and taking new pictures with the camera. - image_picker | pub.dev

フラッター開発チームが作った純正なのでいろいろ安心

Flutterでのプラグインの導入は pubspec.yaml にプラグインの情報を追加して $ flutter pub get してあげるだけ

このプラグインではフォトライブラリへのアクセスを許可しないといけないので、iOSでは以下の変数に関する設定をios/Runner/info.plistに事前に書き込んでおく必要がある

変数名 役割
NSPhotoLibraryUsageDescription フォトライブラリへのアクセス
NSCameraUsageDescription カメラへのアクセス
NSMicrophoneUsageDescription マイクへのアクセス

動画を取り込まないなら一番下の項目は無視できる

細かい設定の仕方は公式ドキュメントをご参照ください

公式ドキュメントをサンプルのコードに手を加え、ペッとmain.dartに貼り付けたら動くものを作った

動作環境はMacBookPro上の android-x86 emulator(Pixel_3a_API_30_x86)である

例えばカメラの起動と写真の取得はたったこれだけ。

カメラの起動・写真の取得
final pickedFile = await picker.getImage(source: ImageSource.camera);

ImageSource.gallery ならフォルダへのアクセスができる

かなり使えそう。

sqflite

SQLite(データベース)を導入・利用するためのプラグイン

Flutter plugin for SQLite, a self-contained, high-reliability, embedded, SQL database engine. - sqflite | pub.dev

導入は例に漏れず pubspec.yaml にプラグインの情報を追加して $ flutter pub get してあげるだけ

公式Githubとか他の記事見ながら理解した

主な関数の説明を列挙していく

まずはデータベースへのパスを取得する必要があるらしいので、import 'package:path/path.dart'しておいて、getDatabasesPath関数でパスを取得する

それを用いると、データベースを開く処理はこんな感じでできる

openDatabase関数
String _path = join(await getDatabasesPath(), "mydb.db");
Database database = await openDatabase(_path, version: 1, onCreate: 処理);

新規にデータベースを作成してアクセスする時などは、openDatabase関数実行時にonCreateを設定すれば良い

onCreate関数は、データを事前準備しておきたい時にも使える

onCreate関数
    String _path = join(await getDatabasesPath(), "mydb.db");

    Database _database = await openDatabase(_path, version: 1,
        onCreate: (Database db, int version) async {
      await db.execute(
          "CREATE TABLE IF NOT EXISTS mydb (id INTEGER PRIMARY KEY, text TEXT)");
      // データを事前に流し込みたい時はここにinsertを記述すれば良い
    });

データベースの内容の書き換えなどで、transaction関数を利用できる

transaction関数
    await _database.transaction((txn) async {
      await txn.rawInsert('INSERT INTO mydb(text) VALUES("$textData")');
    });

レコードの取得にはrawQuery、保存にはrawInsert、更新はrawUpdate、削除はrawDeleteを使えば良い
rawQuery関数の結果はMap型のリストで返ってくる

rawQuery関数
    List<Map> _result = await _database.rawQuery('SELECT * FROM mydb');

SQLとかを発行しない処理であれば、execute関数で行える

本題

カメラとギャラリーから画像を取ってきて、DB(SQLite)に保存するアプリを作ってみた。

まずは完成したアプリをみて欲しい.

Androidエミュレーターでの動き

andq.gif

iPhoneエミュレーターでの動き

iosq.gif

Android、iphoneの両方で動作するアプリがであることが確認できる(ただし、iphoneエミュレーター上ではカメラは起動しない)

sqfliteに画像を保存する場合は画像情報をbase64でエンコードしたテキストとして写真の実体を保存する

自分はこちらを参考にした(Thank you!)

Google’s Flutter Tutorial – Save Image as String in SQLite Database

画像を保存しておくためのテーブルは以下のように定義している

  initDB() async {
    io.Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, DB_NAME);
    var db = await openDatabase(path, version: 1, onCreate: _onCreate);
    return db;
  }

  _onCreate(Database db, int version) async {
    await db.execute("CREATE TABLE $TABLE ($ID INTEGER, $DATA TEXT)");
  }

ギャラリーからピックアップしてきた画像をBase64でエンコードして保存

  _pickImageFromGallery() {
    final picker = ImagePicker();
    picker.getImage(source: ImageSource.gallery).then((imgFile) async {
      if (imgFile != null) {
        String imgString = Utility.base64String(await imgFile.readAsBytes());
        Photo photo = Photo(0, imgString);
        dbHelper.save(photo);
      }
    });
  }

完成したコードはGitHubにおいておきます

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

StoryboardまたはコードでUITableViewを扱う

Storyboardでのやり方と、コードで生成するやり方をまとめてみます
UITableViewControllerには触れてないです

Storyboardで

  • UITableViewを配置して、outlet接続します
  • UITableViewCellを配置して、識別子をつけます
  • UITableViewCellのスタイルを設定します
    customにする場合は、UITableViewCellを継承したカスタムクラスを作る必要があります
    そしてUITableViewCellに必要なものを配置します
  • datasourceを用意して、セルを生成します(下は簡単な例)
class ViewController: UIViewController {

    @IBOutlet weak var myTableView: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()

        myTableView.dataSource = self
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 10
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = myTableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = "\(indexPath.row)"
        return cell
    }
}

コードのみで

  • ViewControllerUITableViewを追加します
class ViewController: UIViewController {
    var myTableView = UITableView()

    override func viewDidLoad() {
        super.viewDidLoad()

        // myTableViewのサイズ設定は必須。frameプロパティで設定しても、autoLayoutで設定しても良い
        self.view.addSubview(myTableView)
    }

    // myTableViewのサイズをautoLayoutで設定する
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        myTableView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            myTableView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 0),
            myTableView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: 0),
            myTableView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0),
            myTableView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0)
        ])
    }
}
  • セル用の識別子を登録します
    customにする場合は、UITableViewCellを継承したカスタムクラスを作る必要があります
    そしてUITableViewCell.selfカスタムクラス.selfに変えます
    override func viewDidLoad() {
        // 省略
        myTableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
        // 省略
    }
  • datasourceを用意して、セルを生成します
class ViewController: UIViewController {

    var myTableView = UITableView()

    override func viewDidLoad() {
        // 省略
        myTableView.dataSource = self
    }

    // myTableViewのサイズをautoLayoutで設定する
    override func viewWillLayoutSubviews() {
        // 省略
    }
}

// 以下のコードはStoryboardの時と同じ
extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 10
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = myTableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = "\(indexPath.row)"
        return cell
    }
}

xibファイルを使う

xibファイルを使う場合、カスタムセルを利用しなければならないです

File > New > File...またはcommand Nで新しいセル用のクラスを作ります
スクリーンショット 2020-09-15 23.46.30.png
xibファイルも一緒に作るのでチェックを入れます
スクリーンショット 2020-09-15 23.46.44.png

xibにはこのように3つのラベルを配置します
スクリーンショット 2020-09-15 23.52.02.png

xibファイル内のViewのclass欄にカスタムセルのクラスを指定します。(自動生成の場合は自動で指定してくれます)
スクリーンショット 2020-09-15 23.53.33.png

xibファイルのViewに配置したものを、カスタムセルのクラスにoutlet接続します
スクリーンショット 2020-09-15 23.57.13.png

あとは上のコードのみの時のコードを少し変えれば動きます

class ViewController: UIViewController {

    var myTableView = UITableView()

    override func viewDidLoad() {
        super.viewDidLoad()

        // myTableViewのサイズ設定は必須。frameで設定しても、autoLayoutで設定しても良い
        self.view.addSubview(myTableView)

        // myTableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
        myTableView.register(UINib(nibName: "myCustomCell", bundle: nil), forCellReuseIdentifier: "xibCell")
        myTableView.dataSource = self
    }

    // myTableViewのサイズをautoLayoutで設定する
    override func viewWillLayoutSubviews() {
        // 省略
    }


}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 10
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // let cell = myTableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        // cell.textLabel?.text = "\(indexPath.row)"
        let cell = myTableView.dequeueReusableCell(withIdentifier: "xibCell", for: indexPath) as! myCustomCell
        cell.leftLabel.text = "\(indexPath.row)"
        cell.centerLabel.text = "\(indexPath.row)"
        cell.rightLabel.text = "\(indexPath.row)"
        return cell
    }
}

最後に

Storyboardとxibファイルを使わず、
完全にコードでUITableViewとカスタムセルを実装する場合は、
コードの記述量がたくさん増えるのがわかると思います。
特にAutoLayoutをコードで書けば非常に長くなります。
どう使い分けるべきかよくわからないですが、
もしコメントで教えていただければ幸いです。

参考記事

コードのみでTableViewを設置する!
.xibを用いたUITableViewCellの実装方法

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