20200324のSwiftに関する記事は13件です。

デフォルトTableViewサンプル集 【操作編】(swift)

はじめに

デフォルトTableViewサンプル集(swift)これのセル選択や編集などの操作編です。

サンプル集

複数選択 複数選択 2 一部選択不可 セル編集
multi_selection multi_selection_2 unselectable_parts cell_editing
メニュー インデックスバー スワイプ
menu section_index swipe

複数選択

セルの複数選択処理です。
こんな感じ。

multi_selection

下記のように設定

tableView.allowsMultipleSelection = true

処理としては上記のようにフラグを true にするだけでできる。

選択したものにチェックマークをつけたい場合は下記も実装。

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)

  // ここ
  cell.accessoryType = tableView.indexPathsForSelectedRows?.contains(indexPath) == true ?  .checkmark : .none
  cell.selectionStyle = .none // 選択時に背景色を変えないため
  return cell
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  if let cell = tableView.cellForRow(at: indexPath) {
    cell.accessoryType = .checkmark // チェックつける
  }
}

func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
  if let cell = tableView.cellForRow(at: indexPath) {
    cell.accessoryType = .none // チェックはずす
  }
}

パターン2(20200325追記)

そういえば左側にチェックマーク置くのも簡単にできたので追記です。

こんな感じ。

multi_selection2

実装は下記3行を追加するだけ!

tableView.isEditing = true
tableView.allowsMultipleSelection = true
tableView.allowsMultipleSelectionDuringEditing = true

一部のセル選択不可

一部のセル選択不可にしたい場合は下記のように設定します。

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)

  // 選択不可にしたい場合にnone設定
  cell.selectionStyle = selectable ? .default : .none 
  return cell
}

func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
  // cell.selectionStyle = .noneだけだとselect自体はできる
   // 選択不可の場合にnilを設定する
  return selectable ? indexPath : nil 
}

cell.selectionStyle = .none だけだとセルタップ時に tableView(_:didSelectRowAt:) は呼ばれるし indexPathForSelectedRow にも値が設定されてしまうので tableView(_:willSelectRowAt:) も設定する。

セルの編集

初期表示で編集モードにしたい場合は tableView.isEditing = true を設定する。

編集ボタンを表示したい場合は navigationItem.rightBarButtonItem = editButtonItemでいける!編集モードの場合は完了ボタンに自動で変わってくれる:clap: UITableViewController の場合は編集ボタン押下で自動で編集モードに変わってくれる:clap::clap:(UIViewController の場合は setEditing(_ editing: Bool, animated: Bool) を実装して tableView.isEditing = true で設定してやる必要がある)

編集モードの場合もセル選択を有効にしたい場合は tableView.allowsSelectionDuringEditing = true を設定する。

こんな感じ

edit

削除

削除ボタンを表示する。

下記を設定する。

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
  if editingStyle == .delete {
    tableDataList.remove(at: indexPath.row) // 先にリストから削除する
    // テーブルの高さが変わるのでこれで囲む
    tableView.beginUpdates()
    tableView.deleteRows(at: [indexPath], with: .automatic)
    tableView.endUpdates()
  }
}

これで削除ボタンタップ時と左にスワイプした時に削除処理がはしる。

追加

追加ボタンを表示する。

下記を設定する。

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
  if editingStyle == .insert {
    // 対象セルの下に追加(先にリストに追加する)
    tableDataList.insert(0, at: indexPath.row + 1)
    tableView.beginUpdates()
    tableView.insertRows(at: [IndexPath(row: indexPath.row + 1, section: 0)], with: .automatic)
    tableView.endUpdates()
  }
}

override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
  return .insert // これを設定しないと削除モードになる
}

移動

セルを移動させる。

下記を設定する。

override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
  return true
}

override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
  // 移動処理
  let element = tableDataList[sourceIndexPath.row]
  tableDataList.remove(at: sourceIndexPath.row)
  tableDataList.insert(element, at: destinationIndexPath.row)
}

削除ボタンを表示したくない場合

override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
  return .none
}

編集モード時にインデントを変更したくない場合(削除or追加ボタンがある場合は変わらない)

override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
  return false
}

移動不可

override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
  return indexPath.row != 0 // 一番上のセルを移動不可に設定
}

上記の設定で一番上のセル(row=0)は移動不可になりましたが他のセルを一番上に移動させることは可能です:frowning2:移動先としても不可にしたい場合は下記も設定する。

override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
        return indexPath.row != 0 ? sourceIndexPath : proposedDestinationIndexPath
    }

ロングタップでメニュー表示

copy

こんな感じのやつすぐ実装できんねや!知らんかった!と思ったら全部 iOS13 で Deprecated だった...

func tableView(_ tableView: UITableView, shouldShowMenuForRowAt indexPath: IndexPath) -> Bool 
func tableView(_ tableView: UITableView, canPerformAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) -> Bool
func tableView(_ tableView: UITableView, performAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?)

代わりにこれを使うらしい tableView(_:contextMenuConfigurationForRowAt:point:)

こんな感じ

menu

override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
  let configuration = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { actions -> UIMenu? in
    let copy = UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc"),
                                identifier: nil)
            { action in
                if let cell = tableView.cellForRow(at: indexPath) {
                  UIPasteboard.general.string = cell.textLabel?.text
                }
            }
            return UIMenu(__title: "Menu", image: nil, identifier: nil, children: [copy])
  }
  return configuration
}

インデックスバー

標準の電話帳アプリとかにあるやつ。

こんな感じ

section_index

テーブルの style が .grouped でも表示されるがドキュメントには .plain のみみたいなことが書いてあるので .plain にしましょう。

The table view must be in the plain style (UITableViewStylePlain).

参考:ドキュメント

func sectionIndexTitles(for tableView: UITableView) -> [String]? {
  return ["A", "B", "C"] // インデックスバーの文字設定
}

// 特殊なことをしないのであれば特に実装しなくても対象のセクションが表示される
func tableView(_ tableView: UITableView, sectionForSectionIndexTitle title: String, at index: Int) -> Int {
  return index 
}

虫眼鏡アイコン下記のようにすると一番上に虫眼鏡アイコンが表示される。

func sectionIndexTitles(for tableView: UITableView) -> [String]? {
  return [UITableView.indexSearch, "A", "B", "C"]
}

使うなら一番上に置けみたいなことが書いてある。

This location should generally be the first title in the index.

参考:ドキュメント

これの使い道はよくわかりません。。。:thinking:

カスタム

下記のように色が設定できる。

tableView.sectionIndexColor = .white // 文字色
tableView.sectionIndexBackgroundColor = .systemPurple // バーの背景色
tableView.sectionIndexTrackingBackgroundColor = .systemRed // ハイライト時の背景色

スワイプ

なんか昔ライブラリ入れてやった気がしますが標準でスワイプアクションがあるそうです。

こんな感じ。

swipe

(めっちゃアクションを追加できるけどたぶんそんな使い方をすることはない)

下記のように実装する。

// 右へスワイプ
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
  let action = UIContextualAction(style: .normal, title: "Action") { (action, view, completionHandler) in
           // なんか処理
           completionHandler(true) // 処理成功時はtrue/失敗時はfalseを設定する
           }
           let configuration = UISwipeActionsConfiguration(actions: [action])
        return configuration
}

// 左へスワイプ
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let action = UIContextualAction(style: .destructive, title: "Action") { (action, view, completionHandler) in
           // なんか処理
           completionHandler(true) // 処理成功時はtrue/失敗時はfalseを設定する
           }
           let configuration = UISwipeActionsConfiguration(actions: [action])
        return configuration
}

Action の style には normaldestructive (削除系)の2種類があります。

スワイプを引っ張り切ると UISwipeActionsConfiguration(actions: [action]) の最初の action が実行されます。

これを無効にした場合は configuration.performsFirstActionWithFullSwipe = false を設定する。

カスタム

action.backgroundColor = .systemBlue // 背景色設定
action.image = UIImage(systemName: "pencil.circle.fill") // 画像設定(タイトルは非表示になる)

さいごに

Table関連は頻繁に処理が追加されてるのでたまにチェックした方がいい。追加されたときは見るけど古いバージョンも動作保証してると使えないのでよく忘れます...たまにチェックしよう!

ドラッグドロップは色々できそうなのでまた時間があれば追加します...

プルリクくれてもええんやで:rolling_eyes:

サンプル集

プルリク大歓迎(だれかきてくれないかな:eyes::eyes::eyes:

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

swiftでキーボードの扱いが面倒なので「IQKeyboardManager」を使ったら神だった

swiftでテキストを入力したりするときにキーボードの操作もしなければいけないのがすごく面倒くさい。

何てったって

・キーボードを表示
・doneをクリックしたらキーボードを隠す

なのですが、これがよく忘れたりきちんと動かなかったり(俺が悪い)、ビルドしてから気づく。なんてことがあったので探してみたら速攻見つかりました。

使用感

簡単に使ってみましたがすごくいい。

https://www.youtube.com/watch?time_continue=4&v=iS1v7tSPUbc&feature=emb_title

参考

https://qiita.com/k-yamada-github/items/d4c791424ead414e3142

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

CombineによるPublisherの受け渡し

はじめに

RxSwiftを利用したVIPERアーキテクチャのソースコードをcombineで書き直している。
RxSwiftでは、InteractorがAPIから非同期処理で取得したデータをPresenterに渡す際、Singleオブジェクトとして扱っていた。SingleだったところをcombineではFutureオブジェクトにしたのだが、両者はGenericsの型の数が異なる。直接参考になるソースを見つけられなかったため我流のものだが書き残しておく。ただ、もっと良い方法があると思われるので、詳しい方にご教示いただきたいというのが正直なところである。

ソースコード

fetchFutureOnPresenter(flag: Bool)trueを入れればStringIntに変換された値が返ってくる。falseにすればPresenterを経由せずにエラーが投げられることをplaygroundで動作確認をした。

struct APIError: Error {
    var description: String
}

// InteractorがAPIにアクセスしてレスポンスを取得する想定
func fetchFutureOnInteractor(flag: Bool) -> AnyPublisher<String, Error> {

    return Future<String, Error>{ promise in

        ///
        /// 実際はここに非同期処理を書く想定
        ///

        // 非同期処理の結果によって、場合分け
        if flag {
            promise(.success("0"))
        } else {
            promise(.failure(APIError(description: "☓")))
        }
    }
    .eraseToAnyPublisher()
}

// PresenterがInteractorのメソッドから取得したデータをViewに渡す用に変換する想定
// flatMapでAnyPublisher<String, Error>からAnyPublisher<Int, Error>に変換
func fetchFutureOnPresenter(flag: Bool) -> AnyPublisher<Int, Error> {
    return fetchFutureOnInteractor(flag: flag)
        .flatMap { output -> AnyPublisher<Int, Error> in // クロージャの戻り値をIntにする
            Future<Int, Error>{ promise in
                promise(.success(Int(output)!))          // Intにキャスト、エラー(Failure)は、ここでは記載しない
            }
            .eraseToAnyPublisher()
    }
    .eraseToAnyPublisher()
}

// Presenterが購読(.sink)
let cancellable = fetchFutureOnPresenter(flag: /* true or false */)
.sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            // Interactorの.failureはここに投げられる
            print("error \(error)")
        }
    }, receiveValue: { value in
        // このvalueを元にViewが参照する値を更新する想定
        print("value \(value)")
    })
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUIのPreview実装をそのまま使って、Screenshot撮影を自動化する

はじめに

SwiftUIのPreview機能を活用して、実装したPreview用のコードをそのままScreenshot撮影自動化に利用できる方法の紹介です。
わざわざテスト用のコードを実装することなく、既存の記述をほぼそのままCIとして組み込めるので、かなり簡単に対応できると思います。
これを使って、画像diffから意図しないUI変更を検知したり、簡単なUIカタログのようなものを作ることもできます。

完成形

先に完成形を記載しておきます

Previewの実装

struct ContentView_Previews: PreviewProvider {
    // Previewableに適合したenumを定義して、previewプロパティで状態に応じたViewを返すだけです
    enum Context: String, Previewable {
        case red
        case green
        case blue

        var preview: some View {
            switch self {
            case .red:
                return ContentView()
                    .foregroundColor(.red)
            case .green:
                return ContentView()
                    .foregroundColor(.green)
            case .blue:
                return ContentView()
                    .foregroundColor(.blue)
            }
        }
    }

    static var previews: some View {
        // Previewableに定義してある便利関数で、各caseごとのViewをGroupで括った上で返却します
        Context.groupedAllContext
    }
}

Preview画面での実際の表示はこんな感じ

テストコードの実装

テスト用の実装はこれだけでOKです

class PreviewScreenshotSampleTests: FBSnapshotTestCase {
    override func setUp() {
        super.setUp()
        // ここは、今回利用したFBSnapshotTestCaseのための実装です
        self.recordMode = true
    }

    func test() {
        // Previewableに適合した型の、screenshotメソッドを呼び出すだけです。
        ContentView_Previews.Context.screenshot(self)
    }
}

画像はこんな感じで出力されます:rocket::rocket::rocket:
Previewで表示されていたものと同じ画面が撮影できているのがわかると思います。

上記の通り、 Previewable に適合した型の preview プロパティから、スクリーンショットを撮影したい状態のViewを返してあげるだけで、SwiftUIのPreviewとUnitTest両方から同じ状態の画面を参照することができました。

以下では仕組みの方の解説をします。

ライブラリの準備

今回は ios-snapshot-test-case を利用してスクリーンショットを撮影しました。
特にライブラリに依存した実装はないので、お好みのライブラリでスクリーンショットを撮影できるはずです。

ios-snapshot-test-case用の設定としてSchemeに以下の環境変数を設定します。
これにより、指定したディレクトリに画像が保存されます。

Preiewableの定義

実装はこれだけです。詳細はコメントに記述しています。

protocol Previewable: CaseIterable, Hashable, RawRepresentable {
    associatedtype Preview: View
    var preview: Preview { get }
}

// Groupを使ってPreiewを分けたり、Stackを使って縦に並べるための便利関数です。
// 無くても問題ありません
extension Previewable where Self.AllCases: RandomAccessCollection, Self.RawValue == String {
    static var groupedAllContext: some View {
        Group {
            ForEach(Self.allCases, id: \.self) {
                // Stringのenumで定義しておけば、case名がそのままPreiew内に表示されるようにしています
                $0.preview.previewDisplayName($0.rawValue)
            }
        }
    }

    static var stackedAllContext: some View {
        VStack {
            ForEach(Self.allCases, id: \.self) {
                $0.preview.previewDisplayName($0.rawValue)
            }
        }
    }
}
extension Previewable where Self.AllCases: RandomAccessCollection, Self.RawValue == String {
    static func screenshot(_ testCase: FBSnapshotTestCase) {
        // 定義されてある全てのケースを1つずつ撮影していく
        Self.allCases.forEach { (ctx) in
            ctx.screenshot(for: testCase)
        }
    }

    func screenshot(_ testCase: FBSnapshotTestCase) {
        let window = UIWindow(frame: UIScreen.main.bounds)
        // previewプロパティから対象の状態のViewを生成します
        window.rootViewController = UIHostingController(rootView: self.preview)
        window.makeKeyAndVisible()
        let expectation = testCase.expectation(description: "")
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            // ここで任意のスクリーンショット撮影処理を実行します。
            // 今回はFBSnapshotTestCaseのメソッドを使うため、画面に表示されたぐらいのタイミングで、UIViewの型でSwiftUIの画面を取り出して受け渡します
            // identifierにrawValueを渡すことで、出力されるファイルの名前を定義したcaseの名前になるようにしています
            testCase.FBSnapshotVerifyView(window.rootViewController!.view, identifier: self.rawValue)
            expectation.fulfill()
        }
        testCase.wait(for: [expectation], timeout: 5.0)
    }
}

以上で必要な実装は終わりです。

サンプルコード

https://github.com/chocoyama/PreviewScreenshotSample

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

AuthKeyを利用してコマンド(curl)でPushテストする

メモ書き

簡単にPushテストできる方法をメモしておきます。

下記のスクリプト叩くだけ

/bin/bash push_test.sh

deviceTokenはこんな感じで事前に取っておく。

AppDelegate.swift
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        var token: String = ""
        for i in 0..<deviceToken.count {
            token += String(format: "%02.2hhx", deviceToken[i] as CVarArg)
        }
        print("push token :\(token)")
    }
}

deviceTokenとAuthKey(デベロッパーページで作成)は事前に用意する。

push_test.sh
#!/bin/bash
deviceToken=dbfacf621430827771306fadb878304e9fd956f509527fc55bfd216915dxxx
authKey="./AuthKey_XXXX43J85J.p8"
authKeyId=XXXX43J85J
teamId=XXXX798F94

# distribution
# bundleId=jp.co.archive-asia.demo
# endpoint=https://api.push.apple.com

# development
bundleId=jp.co.archive-asia.demo
endpoint=https://api.development.push.apple.com

#テスト
 read -r -d '' payload <<-'EOF'
 {
    "aps": {
       "badge": 2,
       "category": "あああ",
       "alert": {
          "title": "タイトル",
          "subtitle": "サブタイトル",
          "body": "ホゲホゲ"
       }
    },
    "custom": {
       "mykey": "カスタムデータ"
    }
 }
 EOF

base64() {
   openssl base64 -e -A | tr -- '+/' '-_' | tr -d =
}
sign() {
   printf "$1"| openssl dgst -binary -sha256 -sign "$authKey" | base64
}
time=$(date +%s)
header=$(printf '{ "alg": "ES256", "kid": "%s" }' "$authKeyId" | base64)
claims=$(printf '{ "iss": "%s", "iat": %d }' "$teamId" "$time" | base64)
jwt="$header.$claims.$(sign $header.$claims)"
curl --verbose \
   --header "content-type: application/json" \
   --header "authorization: bearer $jwt" \
   --header "apns-topic: $bundleId" \
   --data "$payload" \
   $endpoint/3/device/$deviceToken

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

コマンド(curl)でPushテストする方法(iOS)

メモ書き

AuthKeyを利用して簡単にPushテストできるのが分かったのでメモしておきます。

事前作業

  • deviceTokenはこんな感じで事前に取っておく。
AppDelegate.swift
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        var token: String = ""
        for i in 0..<deviceToken.count {
            token += String(format: "%02.2hhx", deviceToken[i] as CVarArg)
        }
        print("push token :\(token)")
    }
}
  • AuthKey(デベロッパーページで作成)を取得

スクリーンショット 2020-03-27 13.37.12.png

スクリプト

push_test.sh
#!/bin/bash
deviceToken=dbfacf621430827771306fadb878304e9fd956f509527fc55bfd216915dxxx
authKey="./AuthKey_XXXX43J85J.p8"
authKeyId=XXXX43J85J
teamId=XXXX798F94

# distribution
# bundleId=jp.co.archive-asia.demo
# endpoint=https://api.push.apple.com

# development
bundleId=jp.co.archive-asia.demo
endpoint=https://api.development.push.apple.com

#テスト
 read -r -d '' payload <<-'EOF'
 {
    "aps": {
       "badge": 2,
       "category": "あああ",
       "alert": {
          "title": "タイトル",
          "subtitle": "サブタイトル",
          "body": "ホゲホゲ"
       }
    },
    "custom": {
       "mykey": "カスタムデータ"
    }
 }
 EOF

base64() {
   openssl base64 -e -A | tr -- '+/' '-_' | tr -d =
}
sign() {
   printf "$1"| openssl dgst -binary -sha256 -sign "$authKey" | base64
}
time=$(date +%s)
header=$(printf '{ "alg": "ES256", "kid": "%s" }' "$authKeyId" | base64)
claims=$(printf '{ "iss": "%s", "iat": %d }' "$teamId" "$time" | base64)
jwt="$header.$claims.$(sign $header.$claims)"
curl --verbose \
   --header "content-type: application/json" \
   --header "authorization: bearer $jwt" \
   --header "apns-topic: $bundleId" \
   --data "$payload" \
   $endpoint/3/device/$deviceToken

実行

スクリプト叩くだけ

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

Swift:マルチディスプレイでもNSStatusBarButtonのアイコンを色を保つ

メニューバーアプリ開発のTipsです.

マルチディスプレイの状態でMacのメニューバーを眺めていると,非アクティブなディスプレイでは基本的にアイコンが色を失ってグレーアウトするのですが,たまにそのお約束を無視している子がいます.

スクリーンショット 2020-03-24 15.07.52.png

Siriのアイコンとか,LINEのアイコンとかです.

これがどうやってるのか気になって調べたり検証したところ,やり方そのものは簡単なのですが,情報が全然なかったので記しておきます.

ポイント

  • NSStatusBarButton.imageにアイコンを設定するだけだとグレーアウトする
  • NSStatusBarButtonにNSViewをaddSubviewしてその中でdrawするとグレーアウトしない
  • NSStatusBarButton.imageにアイコンを設定しないとボタンの大きさや位置が定まらない
  • NSStatusItem.lengthで幅を設定することもできるけれどあまり賢い方法ではない

解決法

  1. NSStatusBarButton.imageにアイコンサイズと同じサイズの透明なNSImageを設定する
  2. 独自NSViewを用意して,アイコンNSImageとNSStatusBarButtonの大きさを渡す
  3. NSStatusBarButtonに独自NSViewをaddSubViewしてマージンを計算して配置

サンプルソース

AppDelegate
import Cocoa

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

    let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    var button: NSStatusBarButton!


    func applicationDidFinishLaunching(_ aNotification: Notification) {
        button = statusItem.button
        button.image = NSImage(size: NSSize(width: 18.0, height: 18.0))
        let icon = NSImage(imageLiteralResourceName: "SampleIcon")
        let iconView = IconView(icon, button.bounds.size)
        button.addSubview(iconView)
    }

    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
        return true
    }

}
IconView
import Cocoa

class IconView: NSView {

    var icon: NSImage?

    init(_ icon: NSImage, _ size: CGSize) {
        super.init(frame: NSRect(x: 0.5 * (size.width - 18.0),
                                 y: 0.5 * (size.height - 18.0),
                                 width: 18.0, height: 18.0))
        self.icon = icon
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func draw(_ dirtyRect: NSRect) {
        icon?.draw(in: NSRect(x: 0.0, y: 0.0, width: 18.0, height: 18.0))
    }

}

サンプルの結果

スクリーンショット 2020-03-24 15.57.04.png

ちゃんと非アクティブなディスプレイでも色を保っています.

ちなみに,メニューバーの高さ上限は22ポイントですが,22ポイントのアイコンだと窮屈なので,高さは18ポイントが良いです.横幅には制限がないです.(18ポイントということは,最近のMacはRetinaディスプレイなので綺麗に表示するなら2倍の36ピクセルが必要です.)

蛇足

NSStatusBarButton.titleに顔文字を指定しても色がついたままになります(色褪せますが).

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

Swift:マルチディスプレイでもNSStatusBarButtonのアイコンの色を保つ

メニューバーアプリ開発のTipsです.

マルチディスプレイの状態でMacのメニューバーを眺めていると,非アクティブなディスプレイでは基本的にアイコンが色を失ってグレーアウトするのですが,たまにそのお約束を無視している子がいます.

スクリーンショット 2020-03-24 15.07.52.png

Siriのアイコンとか,LINEのアイコンとかです.

これがどうやってるのか気になって調べたり検証したところ,やり方そのものは簡単なのですが,情報が全然なかったので記しておきます.

ポイント

  • NSStatusBarButton.imageにアイコンを設定するだけだとグレーアウトする
  • NSStatusBarButtonにNSViewをaddSubviewしてその中でdrawするとグレーアウトしない
  • NSStatusBarButton.imageにアイコンを設定しないとボタンの大きさや位置が定まらない
  • NSStatusItem.lengthで幅を設定することもできるけれどあまり賢い方法ではない

解決法

  1. NSStatusBarButton.imageにアイコンサイズと同じサイズの透明なNSImageを設定する
  2. 独自NSViewを用意して,アイコンNSImageとNSStatusBarButtonの大きさを渡す
  3. NSStatusBarButtonに独自NSViewをaddSubViewしてマージンを計算して配置する

サンプルソース

AppDelegate
import Cocoa

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

    let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    var button: NSStatusBarButton!

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        button = statusItem.button
        button.image = NSImage(size: NSSize(width: 18.0, height: 18.0))
        let icon = NSImage(imageLiteralResourceName: "SampleIcon")
        let iconView = IconView(icon, button.bounds.size)
        button.addSubview(iconView)
    }

    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
        return true
    }

}
IconView
import Cocoa

class IconView: NSView {

    var icon: NSImage?

    init(_ icon: NSImage, _ size: CGSize) {
        super.init(frame: NSRect(x: 0.5 * (size.width - 18.0),
                                 y: 0.5 * (size.height - 18.0),
                                 width: 18.0, height: 18.0))
        self.icon = icon
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func draw(_ dirtyRect: NSRect) {
        icon?.draw(in: NSRect(x: 0.0, y: 0.0, width: 18.0, height: 18.0))
    }

}

サンプルの結果

スクリーンショット 2020-03-24 15.57.04.png

ちゃんと非アクティブなディスプレイでも色を保っています.

ちなみに,メニューバーの高さ上限は22ポイントですが,22ポイントのアイコンだと窮屈なので,高さは18ポイントが良いです.横幅には制限がないです.(18ポイントということは,最近のMacはRetinaディスプレイなので綺麗に表示するなら2倍の36ピクセルが必要です.)

蛇足

NSStatusBarButton.titleに顔文字を指定しても色がついたままになります(色褪せますが).

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

Swift:もしかしたらGPUの状態を取得できるかもしれないコード

RunCatでシステム情報を色々取得してきたので,GPUの状態も取得してみようと思い調べたところ,Objective-Cでやっている先駆者がいたのでSwift化してみました.しかし,自分の開発機のMacBook Proは13インチでGPU積んでないので動作検証できませんでした.

誰か試してみたらコメント欲しいです.

import Cocoa
import IOKit

class ViewController: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        getGPUPerformance()
    }

    func getGPUPerformance() {
        let dict = IOServiceMatching(kIOAcceleratorClassName)
        var iterator = io_iterator_t()
        if IOServiceGetMatchingServices(kIOMasterPortDefault, dict, &iterator) == kIOReturnSuccess {
            while case let regEntry = IOIteratorNext(iterator), regEntry != 0 {
                var services: Unmanaged<CFMutableDictionary>? = nil
                if IORegistryEntryCreateCFProperties(regEntry, &services, kCFAllocatorDefault, 0) != kIOReturnSuccess {
                    IOObjectRelease(regEntry)
                    continue
                }
                if
                    let servicesDict = services?.takeRetainedValue() as? [String : AnyObject],
                    let ps = servicesDict["PerformanceStatistics"] as? [String : AnyObject],
                    let gpuCore = ps["GPU Core Utilization"] as? Double,
                    let freeVram = ps["vramFreeBytes"] as? Double,
                    let usedVram = ps["vramUsedBytes"] as? Double {
                    let outpu = String(format: "GPU: %.3f%%, VRAM: %.3f%%",
                                       gpuCore / 10000000.0,
                                       100.0 * (usedVram / (usedVram + freeVram)))
                    Swift.print(outpu)
                }
                services?.release()
                IOObjectRelease(regEntry)
            }
            IOObjectRelease(iterator)
        }
    }

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

SwiftからOpenCVを使う

概要

基本的にこの記事を参考にしたら上手くできたが、1つ詰まるところがあったので、そこの解消法について記録しておきます。
<参考記事> iOS(swift)でOpenCVを使うシンプルなサンプル

『'opencv2/opencv.hpp' file not found』のエラー

参考記事の

OpenCVSample.mm
#import <opencv2/opencv.hpp>
#import <opencv2/highgui/ios.h>

この部分でfile not foundのエラーが発生した。

General > Build Settings > Framework Search Pathsにて、
$(PROJECT_DIR)をrecursiveで設定したら解消された。

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

Swift:App GroupsとUserDefaultsとNSKeyedUnarchiverの話

iOSでToday Extensionと母機アプリを連携したいときに,App Groupsを使います.そして,UserDefaultsでデータを共有するのですが,独自クラスのデータを共有するとなると少し厄介でした.

App Groupsの準備

データを共有するターゲットのそれぞれにて下記の作業をします.

  1. [Signing & Capabilities]よりApp Groupsを追加
  2. グループIDを追加する(データを共有する全てのターゲットにて同じグループIDを使用すること).

そして,UserDefaultsを使うときはUserDefaults.standardではなくUserDefaults(suiteName: "グループID")を使います.なお,グループIDはgroup.から始まるのが通常らしいです.

独自クラスの準備

基本的にはこの記事と同様ですが,NSCodingだけでなくNSSecureCodingも使います.

独自クラスのサンプル
class Sample: NSObject, NSCoding, NSSecureCoding {

    var dataA: Bool
    var dataB: Double
    var dataC: String

    static var supportsSecureCoding: Bool {
        return true
    }

    init(_ dataA: Bool, _ dataB: Double, _ dataC: String) {
        self.dataA = dataA
        self.dataB = dataB
        self.dataC = dataC
    }

    required init?(coder: NSCoder) {
        dataA = coder.decodeBool(forKey: "dataA")
        dataB = coder.decodeDouble(forKey: "dataB")
        dataC = coder.decodeObject(of: NSString.self, forKey: "dataC") as String? ?? ""
    }

    func encode(with coder: NSCoder) {
        coder.encode(dataA, forKey: "dataA")
        coder.encode(NSNumber(value: dataB), forKey: "dataB")
        coder.encode(dataC as NSString, forKey: "dataC")
    }

}

supportsSecureCodingをtrueにする必要があります.
ここで,ターゲットごとの初期化の場面(AppDelegateのdidFinishLaunchingとかViewDidLoadとか)でNSKeyedArchiverNSKeyedUnarchiverに独自クラスを登録します.

override func viewDidLoad() {
    super.viewDidLoad()
    NSKeyedArchiver.setClassName("Sample", for: Sample.self)
    NSKeyedUnarchiver.setClass(Sample.self, forClassName: "Sample")
}

データの取得/保存

let userDefaults = UserDefaults(suiteName: "group.com.hoge.sampleApp")!

var sample: Sample {
    get {
        guard
            let data = userDefaults.data(forKey: "sample"),
            let unarchived = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data),
            let sample = unarchived as? Sample
            else {
                return Sample(false, 0.0, "")
        }
        return sample
    }
    set(newSample) {
        let data = try? NSKeyedArchiver.archivedData(withRootObject: newSample,
                                                     requiringSecureCoding: true)
        userDefaults.set(data, forKey: "sample")
    }
}

このようにgetsetを定義してしまうとデータの出し入れがしやすいかもしれません.

参照

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

iOS13のautomaticモーダルからfullScreenに横遷移する

こーゆう遷移をする方法を紹介します。

前置き

macOS 10.15.x
Xcode 11.2.x
iOS 13.x

画面遷移について、iOS13から通常のモーダル表示(下から表示)が、
ちょっと立体的な .automatic 表示になりました。

.automatic .fullScreen

iOS12以前は通常が.fullScreenでしたね。
.fullScreenで表示したい場合は、
ちゃんと指定するようにしなければならなくなりました。

let viewController = xxx
let navigationController = UINavigationController(rootViewController: viewController)
navigationController.modalPresentationStyle = .fullScreen
present(navigationController, animated: true, completion: nil)

この記事は.automaticの状態から、.fullScreenの状態に、
pushViewController 遷移のように横表示する方法を記載します。

方法1(?): pushViewController遷移

-> 現状わかりませんでした

色々調べましたが、同じnavigationControllerで、
modalPresentationStyleを変更する方法は現状見つけられませんでした。

もし方法が存在し、やり方を知っている方がいましたら教えてくださいm(_ _)m

方法2: present遷移の横バージョン

present(viewController, animated: true, completion: nil)
の遷移って下からですよね。

コレを横から遷移に変更する方法が、方法2です。

横から遷移サンプルコード:

import UIKit

final class TestFirstViewController: UIViewController {

    @IBAction private func clickButton(_ sender: Any) {
        let storyBoard = UIStoryboard(name: "TestSecond", bundle: nil)
        let viewController = storyBoard.instantiateViewController(withIdentifier: "TestSecondViewController")
        let navigationController = UINavigationController(rootViewController: viewController)
        navigationController.modalPresentationStyle = .fullScreen
        let transition = CATransition()
        transition.duration = 0.4
        transition.type = .push
        transition.subtype = .fromRight
        view.window!.layer.add(transition, forKey: kCATransition)
        present(navigationController, animated: false, completion: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

ポイントは、

  • CATransitionを利用して、presentのanimationを定義していること
  • presentのanimatedはfalseにすること

です。ちなみに transition.typetransition.subtype
変更すれば、他にも様々な画面遷移アニメーションを実現できます。

また、せっかく.automatic状態から横表示を実現しても、
何も設定していなければ、画面を閉じるアニメーションは下方向になってしまいます。
画面を閉じる動作も、横に設定してあげましょう。

横へ画面閉じるサンプルコード:

import UIKit

final class TestSecondViewController: UIViewController {

    @objc private func clickBackBarButtonItem() {
        let transition = CATransition()
        transition.duration = 0.4
        transition.type = .push
        transition.subtype = .fromLeft
        view.window!.layer.add(transition, forKey: kCATransition)
        dismiss(animated: false, completion: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
    }

    private func setupViews() {
        let backBarButtonItem = UIBarButtonItem(title: "戻る", style: .plain, target: self, action: #selector(clickBackBarButtonItem))
        navigationItem.setLeftBarButton(backBarButtonItem, animated: true)
    }
}

表示時と同じく、dismissのanimatedがfalseなことに注意しましょう。

ちなみに

safariViewControllerを用いた場合は、特に何も指定なく横遷移にできるようです。

let safariViewController = SFSafariViewController(url: URL(string: "https://google.com")!)
present(safariViewController, animated: true, completion: nil)

まとめ

本当は方法1の方で実現できれば楽なのですが。
今後のアップデートに期待です。

参考

https://swallow-incubate.com/archives/blog/20200226/
https://zonneveld.dev/ios-13-viewcontroller-presentation-style-modalpresentationstyle/

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

MusicアプリのTabBarControllerを再現してみた

iPhone、iPadの標準ミュージックアプリのプレイヤー画面付きのTabBarControlelrを再現してみたので、紹介したいと思います。
※音楽アプリ専用というわけではないのでプレイヤー画面以外の好きな画面を表示することはできますが、この記事の中ではプレイヤー画面と言わせていただきます。

完成したもの

https://github.com/HiroteruWatanabe/OverlayTabBarController
ソースを見たい方はこちらからどうぞ! CocoaPodsでも導入可能です。
CocoaPodsの作成に慣れていないので、いろいろ荒いところはありますが、気にしないでください。

動作環境

iOS 13.0以上
Swift 5

iPhone

iPhoneの場合はタブバーの上にプレイヤーのプレビュー画面が表示されます。
プレビュー画面をタップするとプレイヤーをモーダル表示します。
プレイヤー画面の高さによって表示形式が変わります。

プレイヤー非表示時 プレイヤー表示時 プレイヤー表示時(セミモーダル)
Simulator Screen Shot - iPhone 11 - 2020-03-03 at 01.47.59.png Simulator Screen Shot - iPhone 11 - 2020-03-03 at 01.48.02.png Simulator Screen Shot - iPhone 11 - 2020-03-06 at 01.34.39.png

iPad

iPadの場合はタブバーの右横にプレビュー画面が表示されます。

プレイヤー非表示時 プレイヤー表示時
Simulator Screen Shot - iPad Pro (12.9-inch) (3rd generation) - 2020-03-03 at 01.15.17.png Simulator Screen Shot - iPad Pro (12.9-inch) (3rd generation) - 2020-03-03 at 01.15.19.png

使い方

基本的にはプレイヤー画面とプレビュー画面を生成してから以下のメソッドを実行するだけです。

// overlayViewControlelr:プレイヤー画面
// previewingViewController;プレビュー画面
// isExpanded:プレイヤーを表示するかどうか
setOverlayViewController(overlayViewController, previewingViewController: previewingViewController, isExpanded: false)

実装

OverlayTabBarControllerというクラスをUITabBarControllerのサブクラスとして作成しました。
タブバーの上(横)にプレイヤー画面を持てるTabBarControllerです。

タブバー

UITabBarControllerのタブバーは位置を調整するのが難しかったので、デフォルトのタブバーは見えないようにして同じ見た目のタブバーを追加しました。

// 実際にユーザーに見せるタブバーのセットアップ
private func setupFlexibleTabBar() {
    tabBar.isHidden = true // デフォルトのタブバーは隠しておく
    if let flexibleTabbar = flexibleTabBar {
      flexibleTabbar.removeFromSuperview()
    }

    flexibleTabBar = UITabBar()
    flexibleTabBar.barStyle = tabBar.barStyle
    flexibleTabBar.translatesAutoresizingMaskIntoConstraints = false
    view.addSubview(flexibleTabBar)
    /*
     iPhoneの場合はデフォルトのタブバーと同じようにレイアウトする
   iPadの場合はプレイヤー画面を右側に設置するので、その分幅を小さくする
    */
    updateFlexibleTabBarConstraints() 

   //デフォルトのタブバーのタブをコピーする
    updateFlexibleTabBarItems()

    /*
     プレビュー画面用のタブバーを設置する
     iPhoneの場合はプレビュー画面がない場合は非表示にする
   iPadの場合は常にタブバーの右側に表示する
    */
    setupPreviewingTabBar()
  }

このようにデフォルトのタブバーを非表示にし、新たにタブバーを追加することでAutoLayoutで自由に位置を調整することができました。

プレビュー画面(previewingViewController)

プレビュー画面はプレイヤーを表示していない時にタブバーの上、または横に表示されている画面です。
画像の「Tap Here」と書かれているところです。

プレビュー画面.png

タブバーと見た目を統一するため、背景にUITabBarを配置しています。(プレビュー画面の背景を透明にする必要があります。)

プレイヤー画面(overlayViewController)

プレビュー画面をタップするとプレイヤー画面が表示されます。

setOverlayViewControllerメソッドでプレイヤー画面とプレビュー画面をセットします。
画面の横幅などの条件からどのように表示するかを決定します。

private func setOverlayViewController(_ overlayViewController: UIViewController, previewingViewController: UIViewController, isExpanded: Bool, animated: Bool = true, viewHeight: CGFloat?) {
    self.previewingViewController = previewingViewController
    self.overlayViewController = overlayViewController
    addChild(previewingViewController)
    if isHorizontalSizeClassRegular {
      setOverrideTraitCollection(UITraitCollection(traitsFrom: [UITraitCollection(horizontalSizeClass: .regular), UITraitCollection(verticalSizeClass: .regular)]), forChild: previewingViewController)
      setOverrideTraitCollection(UITraitCollection(traitsFrom: [UITraitCollection(horizontalSizeClass: .regular), UITraitCollection(verticalSizeClass: .regular)]), forChild: overlayViewController)
    } else {
      setOverrideTraitCollection(UITraitCollection(traitsFrom: [UITraitCollection(horizontalSizeClass: .compact), UITraitCollection(verticalSizeClass: .regular)]), forChild: previewingViewController)
      setOverrideTraitCollection(UITraitCollection(traitsFrom: [UITraitCollection(horizontalSizeClass: .regular), UITraitCollection(verticalSizeClass: .regular)]), forChild: overlayViewController)
    }

    isOverlayViewExpanded = isExpanded
    setupPreviewingTabBar()
    setupButterflyHandle()

    previewingTabBar.addSubview(previewingViewController.view)
    previewingViewController.didMove(toParent: self)

    overlayViewController.view.isHidden = !isExpanded

    //presentsOverlayViewAsModalでモーダル表示するかどうかを判断する
    if presentsOverlayViewAsModal(viewHeight: viewHeight) {
      // モーダル表示する場合は自動的に角丸がつくので、角丸をつける処理を行わない
      overlayViewController.view.layer.masksToBounds = false
      overlayViewController.view.layer.cornerRadius = 0
      if isExpanded {
        presentOverlayViewController(animated: animated, completion: nil)
      }
    } else {
      // モーダル表示でない場合はChildViewControllerとして登録する
      addChild(overlayViewController)
      view.addSubview(overlayViewController.view)
      setupOverlayViewConstraints(viewHeight: viewHeight)
      overlayViewController.view.layer.masksToBounds = true
      overlayViewController.view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
      overlayViewController.view.layer.cornerRadius = 12
      overlayViewController.didMove(toParent: self)
    }

    setupGestureRecognizers(view: previewingViewController.view)
    setupGestureResponder()
    gestureResponder?.isUserInteractionEnabled = isOverlayViewExpanded

    view.bringSubviewToFront(flexibleTabBar)
    isOverlayViewTemporaryRemoved = false
  }

UITabBarControllerにChildViewControllerを追加するとタブバーにそれが表示されることがあります。
プレビュー画面とプレイヤー画面をChildViewControllerとして登録してもタブバーに表示されないようにするため、以下のようにviewControllersをoverrideしています。
やっていることとしては、viewControllersにoverlayViewControllerとpreviewingViewControllerがあれば、除外しているだけです。

override open var viewControllers: [UIViewController]? {
    set {
      guard let overlayViewController = overlayViewController,
        let previewingViewController = previewingViewController else {
          super.viewControllers = newValue
          return
      }
    // プレビュー画面とプレイヤー画面が含まれていたら除外する
      super.viewControllers = newValue?.filter({ $0 != overlayViewController && $0 != previewingViewController })
    }
    get {
      guard let overlayViewController = overlayViewController, let previewingViewController = previewingViewController else { return super.viewControllers }
    // プレビュー画面とプレイヤー画面が含まれていたら除外する
      return super.viewControllers?.filter({ $0 != overlayViewController && $0 != previewingViewController })
    }
  }

あまり綺麗なやり方ではないのかもしれないですが、他に思いつかなかったのです。。

画面サイズ変更時の処理

iPadのSplitViewや画面回転に対応するため、以下のメソッドをオーバーライドしています。
willTransitionとtraitCollectionDidChangeだけではtraitCollectionに変化がないiPadの画面回転に対応できないので、viewWillTransitionもオーバーライドしています。

override open func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    guard !isOverlayViewTemporaryRemoved else { return }
   // 一旦OverlayViewControllerを画面から取り外す
    temporaryRemoveOverlayViewController()
    setupButterflyHandle()
    setupGestureResponder()
   // 新しい画面サイズに合わせてOverlayViewControllerを設置する
    layoutOverlayView(viewHeight: size.height)
  }

  override open func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
    super.willTransition(to: newCollection, with: coordinator)
    guard traitCollection.horizontalSizeClass != newCollection.horizontalSizeClass else { return }
    guard overlayViewController != nil else { return }
   // 一旦OverlayViewControllerを画面から取り外す
    temporaryRemoveOverlayViewController()
    setupButterflyHandle()
    setupGestureResponder()
  }

  override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    guard traitCollection.horizontalSizeClass != previousTraitCollection?.horizontalSizeClass else { return }
    // 新しい画面サイズに合わせてOverlayViewControllerを設置する
    layoutOverlayView()
  }

  private func layoutOverlayView(viewHeight: CGFloat? = nil) {
    if let previewView = previewingViewController?.view {
      previewView.setNeedsLayout()
    }

    setupFlexibleTabBar()
    if let overlayViewController = overlayViewController, let previewViewController = previewingViewController {
      setOverlayViewController(overlayViewController, previewingViewController: previewViewController, isExpanded: isOverlayViewExpanded, animated: false, viewHeight: viewHeight)
      updateView(viewHeight: viewHeight)
    }
    view.setNeedsLayout()
  }

最後に

Musicアプリのタブバーではタブバーの幅が小さくなるとiPhoneのタブバーのようにタブアイテムのアイコンの下にタイトルが表示されるようになるのですが、OverlayTabBarControllerではそれが実現できず、常にアイコンの横にタイトルが表示されています。
これを実現する方法をご存知の方がいましたら、是非とも教えていただきたいです!

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