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

Decodableで未知のenumの値がきたときの対策

課題

APIのレスポンスをDecodableを使ってstructにdecodeしたりしますよね。
そのstructにenumが含まれていた場合にこんな感じになります。

struct Hoge: Decodable {
  enum Type: String, Decodable {
    case type1
    case type2
  }

  let type: Type
  ...
}

しかし、将来的に仕様の追加があり、typeの種類が増やしてしまうと、上記のようにしていると過去にリリースしたアプリがdecodeに失敗してしまいます。

enumの種類が増やすということはAPIの後方互換性のない変更となるので、クライアント側からするとこれは致し方ない問題ではあります。

が、諸事情で、decode失敗ではなく知らないenumが来たとき用の処理を入れておいてAPIバージョンあげずにAPI変更したいケースがもしかしたらあるかもしれない・・・のでそのときの対策です。

対策

なんでもよいのですがunknownのように知らないenumの値用のcaseを追加します。
そしてinit(from decoder)を実装し、decodeに失敗したときに追加したcaseになるようにします。

struct Hoge: Decodable {
  enum Type: String, Decodable {
    case type1
    case type2
    case unknown

    init(from decoder: Decoder) throws {
      self = try DisplayPosition(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
    }
  }

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

CoreNFCのFelica(NFC Type F)

はじめに

前回までで一応、NFC Type F のリードまではできるようになっていました。
今回はちょっとだけ補足とまとめをしますね。

処理の流れ

Session

まずは CoreNFC のセッションを作成します。

let session = NFCTagReaderSession(pollingOption: [.iso14443, .iso18092],
                                  delegate: self)
session?.begin()

このとき、Type F 以外にも Type A/B も同時にスキャンしたい時はこんな感じになります。
もちろん、Type F だけで良い場合は .iso18092 を指定すれば OK です。

スキャンして NFC タグが見つかったら、デリゲートが呼び出されます。
Type A/B/F でスキャンしている場合は、TypeA/B と Type F でアクセスする実装が違ってくるので、ここで判別することになります。
大雑把にはこんな感じです。

func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
    let t : NFCTag = tags.first!
    switch t {
    case .feliCa:
        // Type F
    case .iso7816:
        // Type A/B
    default:
        return
    }
}

Service

タグに接続できたらアクアスしたいサービスを指定します。
サービスコードはリトルエンディアンになります。

let serivces = [Data([0x8b, 0x00])]
feliCaTag.requestService(nodeCodeList: serivces,
                         completionHandler: {
    (nodes, error) -> Void in
})

Read

サービスを指定したらリードできます。
アクセスしたいブロックを指定します。Type F ではブロック単位でのリード・ライトになるんですが、1ブロックは16バイトのバイト列になります。swift なら Data 型ですけどね。
ただ、この時もサービスをしている必要があるのと、ブロックの指定もリトルエンディアンです。

let serivces = [Data([0x8b, 0x00])]
let blockList = [Data([0x80, 0x00])]
feliCaTag.readWithoutEncryption(serviceCodeList: serivces,
                                blockList: blockList,
                                completionHandler: {
    (status1, status2, dataList, error) in
})

ステータスフラグとデータとエラーが取得できます。
ちなみに本来の NFC の通信なら、ここでレスポンスコードとか PICC とかも取得できるみたいなんですが、端折られるみたいなんです。
ま、普通はそこまで不要だとは思うんですけど、取得する方法はあるんでしょうかね?

Write

サービスを指定したらライトもできます。
ほぼリードと同じですね。ライトしたいデータを渡せるようになっているのが違いです。

feliCaTag.writeWithoutEncryption(serviceCodeList: services,
                                 blockList: blockList,
                                 blockData: blockData,
                                 completionHandler: {
    (status1, status2, error) in
})

まとめ

処理の流れをまとめてみました。
Type A/B はちょっとだけ厄介ですが、Type F はずいぶんスッキリした API でアクセスできるのが良いですね。
今回は暗号なしでしたが、暗号ありはサービス提供者との契約が必要になるみたいです。

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

#2 画面の遷移 - はじめてのSwift UI

1.はじめに

アプリと言ったら画面遷移については避けて通ることができません。この辺りの使用がUIKitと結構違うなあと思ったので書いていきます。

また、2020/1/26時点での記事ですのでご了承ください。

2.初期画面の設定

画面遷移のやり方を説明する前にまず初期画面の設定方法を書いていきます。

UIKitではStoryboard上で「Is Initial View Controller」から設定していましたが、SwiftUIの場合は「SceneDelegate.swift」上で設定します。

SwiftUIでプロジェクトを作成すると以下のような「SceneDelegate.swift」が作られていると思います。

SceneDelegate.swift
import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    ...省略...

}


上記ファイル内の以下の箇所でcontentViewに設定したViewクラスが初期画面になります。

SceneDelegate.swift
let contentView = ContentView()

例えば別途Viewを継承したSwiftUIViewクラスを作成していた場合は、以下のように設定することが可能です。

SceneDelegate.swift
let contentView = SwiftUIView()

3.画面遷移

3-1.画面遷移(不具合報告あり?)

画面遷移を説明していきたいのですが、基本となる(であろう)方法を紹介します。※実用的な方法を知りたい方は3-2に進んでください。

SceneDelegate.swift
struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination:
                    NextView()) {
                    Text("遷移する")
                }
            }
        }
    }
}


struct NextView:View{
    var body: some View {
        Text("遷移に成功しました")
    }
}

これはContentViewからNextViewに遷移する場合の例です。
具体的には以下の部分で繊維の処理を行っています。

SceneDelegate.swift
        NavigationView {
            VStack {
                NavigationLink(destination:
                    NextView()) {
                    Text("遷移する")
                }
            }
        }

NavigationView→VStack→NavigationLinkと3段の入れ子構造になっています。VStackはHStackでも問題ないですが、とりあえずこのイメージを暗記しましょう。

VStack(HStack)を入れない場合、コンパイルエラーにはなりませんが正しく画面が遷移しなくなります。(なんでだろう。)

NavigationViewの引数Distinationに遷移先のViewクラス、{}内には画面遷移するリンクになるものをおきます。(基本はTextで問題ないかと思いますが他にも試してみてもいいかもしれません)

これで、「遷移する」テキストをクリックすると「NextView」に遷移する動きを実装できました。。。が、しかし!

このNavigationLink、Defaultだと遷移先で「Back」ボタンが表示され、前の画面に戻ることができるのですが、再度同じNavigationLinkをクリックすると遷移できません。
異なるNavigationLinkであれば遷移可能ですが、一度でも「戻った直後にまた同じ遷移先に遷移」しようとすると、異なるNavigationLinkすらも動かなくなります。

あまり詳しく調べていませんが、多分不具合です。
直るのを待つか、現時点ではこの「Back」ボタンを使わない実装にするのがベターのように思います。

3.2 DefaultのBackボタンを使わない画面遷移

DefaultのBackボタン(NavigationBarBackButton)がうまく使えないみたいなので、改めて画面遷移を作りました。それがこちら。

SceneDelegate.swift
struct ContentView: View {
    @State private var showAfterView: Bool = false
    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination:
                Button(action: {self.showAfterView = false}){
                    Text("遷移元に戻る")
                }.navigationBarBackButtonHidden(true),isActive: $showAfterView) {
                    Button(action: {self.showAfterView = true}){
                        Text("遷移する")
                    }
                }

            }
        }
    }
}

「あれれ〜?Viewが1つしかないぞ〜?」

そうなんです。これ、よく見るとわかりますが、一つのViewクラスの中に遷移先のView構造も含まれています。

また3階層以上の画面遷移は今挑戦中ですが一筋縄ではいかなさそうです。

誰か偉い人、早くBackボタンを直してください。

とまあ、怒りの感情は置いておいて
簡単にコードの説明をします。

@State private var showAfterView: Bool = false

これは、2つの画面のうちを表示するか判断するために使用します。
ざっくりとした説明になりますが、こういったViewの切り替え(更新)に使用する変数には@Stateを付けておく必要があるようです。

var body: some View {
        NavigationView {
            VStack {
                NavigationLink(

この辺は3-1でも説明しましたが、暗記しましょう。
次にNavigationLink周辺を説明します。

NavigationLink(destination:
                Button(action: {self.showAfterView = false}){
                    Text("遷移元に戻る")
                }.navigationBarBackButtonHidden(true),isActive: $showAfterView) {
                    Button(action: {self.showAfterView = true}){
                        Text("遷移する")
                    }
                }

destinationに入れる値を画面遷移フラグをFalseにするButtonにしています。VStackやText等、遷移先のView構造をここに書いても大丈夫なのです。
そして、navigationBarBackButtonをtrueにすることで遷移先のBackボタンを非表示にできます。

また、isActive引数にBool値を設定し、Trueになった時にそのNavigationLinkが動くようにしています。

{}内にはナビゲーションリンクにするViewを指定できますが、ここでは画面遷移フラグをTrueにするButtonを指定します。

おわりに

とりあえず現時点での画面遷移をざっと書きました。
3-1で問題なく動くようになれば不要になる記事なので、とりあえずざっと。
わかりにくい場合はSwiftのアップデートを待ちましょう。

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

SwiftUIとSpeech Frameworkで動画の文字起こしアプリを作ってみる

音声認識に興味が出たので、Apple製のSpeech FrameworkとSwiftUIを使って簡易的なMacアプリ作ってみたので得られた知見をご紹介します。

完成品

ニュース動画のファイルを選択して、音声認識しています。
SpeechRecognizer.mov.gif

ニュース動画なのでアナウンサーの声だけでノイズがないからか、かなりの精度:ok_hand:

開発環境

  • SwiftUI
  • Speech Framework
  • Swift5.1
  • macOS 10.15.2(Catalina)
  • Xcode 11.3.1

MacでSpeech FrameworkとSwiftUI使えるのはCatalina以降なので最新版にアップデートしましょう

SwiftUIベースのMacアプリ作成

create new projectからmacOSのAppを選択し、User Interfaceの項目をSwiftUIにします

スクリーンショット 2020-01-25 15.40.56.png

初期状態で作成されるContentView.swiftにレイアウト定義と音声認識の処理をつらつら書いていきます。
SwiftUIといえど、Viewに処理を書くのはあまり良くありませんが、今回は簡易的なアプリなので、 全部Viewに処理を書いてしまいます

ContentView.swift
import SwiftUI
import Speech

struct ContentView: View {
    @State var recognizedText: String?
    @State var message: String = ""
    private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "ja-JP"))

    var body: some View {
        VStack(alignment: .trailing) {
            Text(recognizedText ?? "")
                .font(.body)
                .frame(width: 480, height: 320, alignment: .top)
                .border(Color.gray)
                .padding()
            HStack {
                Text(message)
                Button("Choose file") {
                    SFSpeechRecognizer.requestAuthorization { (status) in
                        guard status == .authorized else {
                            print("音声入力が認可されていません")
                            return
                        }
                        // NSOpenPanelはMain Threadからのみアクセス可
                        DispatchQueue.main.async {
                            let panel = NSOpenPanel()
                            let result = panel.runModal()
                            guard result == .OK, let url = panel.url else {
                                print("ファイル読み込みに失敗")
                                return
                            }

                            let speechRequest = SFSpeechURLRecognitionRequest(url: url)
                            self.message = "音声認識中..."
                            self.recognizedText = ""
                            _ = self.speechRecognizer?.recognitionTask(with: speechRequest, resultHandler: { (speechResult, error) in
                                guard let speechResult = speechResult else {
                                    return
                                }

                                if speechResult.isFinal {
                                    self.message = "音声認識が完了しました"
                                    print("Speech in the file is \(speechResult.bestTranscription.formattedString)")
                                } else {
                                    let text = speechResult.bestTranscription.formattedString
                                    self.recognizedText = text
                                }
                            })
                        }
                    }
                }
            }.padding()
        }
    }
}

コード解説

@State

SwiftUIのホットリロード機能の一番簡単な@Stateを用いて、UI更新処理を書かないようにしています

NSOpenPanel

Macのファイルダイアログをプログラムから呼ぶのはNSOpenPanelというクラス使うらしいです。初めてしりました。Viewファイルに書いてあるから大丈夫かと思いきや明示的にMainスレッド指定しないと実行時エラーになってしまうので注意

SFSpeechRecognizer

今回のアプリの肝のクラスですね
日本語の文字起こしを想定しているので ローカル情報をja-JPにしたインスタンスを保持。
ユーザ認可をリクエストしたのち、ファイルダイアログから取得できたファイルのURLでSFSpeechURLRecognitionRequestのインスタンスを作って、認識タスクをコールバックと同じく登録して完了です。

コールバックで返却されるSFSpeechRecognitionResultに認識結果が返ってくるので、それを画面に表示してあげるだけで完了です。
.bestTranscription.formattedStringというプロパティにいわゆる文字起こし結果が入ってきますが、他にも声の抑揚や、話す早さなどが返ってくるのが面白いところなので、興味ある方は色々デバッグして見てみると良いかもしれません。

注意点

plistに音声入力の説明を定義忘れずに

NSSpeechRecognitionUsageDescriptionに音声入力を許可するダイアログ時の文言をセットを忘れずに
最近めっきりプライバシーに厳しいAppleフレームワーク。他のフレームワーク同様、音声入力もユーザ認可が必要なのであしからず。

音声認識できる時間の上限が1分

オンラインを介した音声認識のみ、上限が設けられています。
これはそもそもiOS等々のキーボードからの音声入力が1分という上限があるかららしい。(バッテリーや通信量に配慮する為)
1分ごとに区切ってタスクを捌いていけば、長時間動画の完全文字起こしもいけそう?

iOS13から端末上のみで音声認識できるようになり、そちらなら上限はないようだが、対応言語が絞られる(日本語非対応)上に継続的に改善されるオンラインとは違い、精度が良くないとのこと。この辺はトレードオフですね。

まとめ

SwiftUIとSpeech Framework凄すぎ

音声認識の知識ほぼ皆無な私でも簡単なアプリ作れちゃうくらいシンプルなインターフェイスと使いやすいフレームワークな上に精度もかなりでビックリしました。
Macアプリは殆ど作ったことない私でもこれ、ググったりするだけで1時間くらいで作成できました(むしろこのQiita書くほうが時間かかってる。。。)

参考にした記事

[iOS] 最新のSpeech Recognitionについて

【iOS】Speech Frameworkの実装

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

【Swift】Lazyプロパティの使い方に気をつける(ドキュメントの確認は大事)

普段使っているものの
実は注意しないと予期せぬ動作を起こしてしまう可能性があるものは
意外とたくさんあると感じています。

そういう時
Swiftのドキュメントを見てみると
注意書(NOTE)があったりして役に立ちます。

今回は一つの例として
lazyのついたプロパティについて見てみたいと思います。

Lazy Stored Property(遅延格納プロパティ)とは?

Swiftでは
lazyという修飾子を使うことで
最初に利用されるまで
初期化処理を走らなくさせることができます。

これを
Lazy Stored Property(遅延格納プロパティ)
と呼びます。

どういうときに使う?

下記のような場合に役に立ちます。

  • 初期値がインスタンス生成後の状態(他のプロパティなど)に依存している
  • セットアップが複雑で重く、使われるまでは生成する必要がない

例えば
下記のようなCalendarクラスを考えてみます。

class Calendar {
    private lazy var formatter: DateFormatter = {
        print("call")
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        formatter.locale = Locale.init(identifier: "ja_JP")
        return formatter
    }()

    var today: String {
        formatter.string(from: Date())
    }
}

let c = Calendar()

formatterの初期化処理は
Calendarインスタンスを生成した時点では
呼ばれませんが

c.today 

// call

とtodayプロパティ経由で
formatterに始めてアクセスした時点で
初期化処理が呼ばれるようになります。

そして一度初期化処理が走ると
値がメモリに保存されるため
再度処理は走りません。

let c = Calendar()
c.today
c.today

// call ※1回しか出力されない

実は複数回呼ばれる場合がある

しかし
これには例外があります。

Swiftのドキュメントによると

NOTE
If a property marked with the lazy modifier is accessed by multiple threads simultaneously 
and the property has not yet been initialized, 
there is no guarantee that the property will be initialized only once.

↓のLazy Stored Propertiesの項
https://docs.swift.org/swift-book/LanguageGuide/Properties.html

マルチスレッドで同時にアクセスされた場合に
まだ初期化処理が終わっていないと
1回しか呼ばれないことは保証されない。

とあります。

先ほどのソースを少し変更してみます。

class Calendar {
    private lazy var formatter: DateFormatter = {
        sleep(1)
        print("call")
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        formatter.locale = Locale.init(identifier: "ja_JP")
        return formatter
    }()

    var today: String {
        formatter.string(from: Date())
    }
}

初期化処理で少しsleepします。

そして2つのキューからアクセスするようにしてみると

let q1 = DispatchQueue(label: "1")
let q2 = DispatchQueue(label: "2")

let c = Calendar()
q1.async { print(c.today) }
q2.async { print(c.today) }

// call
// call
...

初期化処理が2回走ることがわかりました。

この中で
もし値の変更など
副作用を起こすような処理が入っていたら
2回値を変更してしまった結果
予期せぬ不具合を引き起こす可能性があるので
注意が必要です。

Stored Type Property(格納型プロパティ)は1回しか呼ばれない

ここで似たようなものとして
Stored Type Property(格納型プロパティ)
があります。

Stored Type Propertyはインスタンスに属しておらず
classstaticという修飾子がついた
型自体に属しているプロパティです。

そしてSwiftのドキュメントに
下記の記載があります。

Stored type properties are lazily initialized on their first access. 
They are guaranteed to be initialized only once, 
even when accessed by multiple threads simultaneously, 
and they do not need to be marked with the lazy modifier.

↓のType Propertiesの項
https://docs.swift.org/swift-book/LanguageGuide/Properties.html

つまり
Stored Type Propertyの場合には
マルチスレッドで同時にアクセスされたとしても
必ず1回だけ処理が走るということを保証しています。

先ほどのformatterプロパティを
Type Propertyに変えて
同じことをやってみます。

class Calendar {
    private static var formatter: DateFormatter = {
        sleep(1)
        print("call")
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        formatter.locale = Locale.init(identifier: "ja_JP")
        return formatter
    }()

    var today: String {
        Calendar.formatter.string(from: Date())
    }
}

let q1 = DispatchQueue(label: "1")
let q2 = DispatchQueue(label: "2")

let c = Calendar()
q1.async { print(c.today) }
q2.async { print(c.today) }

// call ※ 1回だけしか出力されない

1回しか初期化処理が走らないことがわかりました。

まとめ

lazyなプロパティは
不要なメモリの消費やパフォーマンスの低下を防ぐことができますが
使い方を誤ると
わかりづらい不具合を混ぜてしまう可能性があることがわかりました。

このlazyのように
Swiftのドキュメントには
実は気をつけなければいけないことなどの
情報が書いてありますので
一度全てに目を通しておくことや
定期的にチェックすることは大事ですね?

もし何か間違いなどございましたら
ご指摘いただけますと嬉しいです??‍♂️

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

UIKitでNeumorphismのデザインを構築するライブラリ

Neumorphism(ニューモーフィズム)は、UIデザインの次のトレンドになるかもしれないと昨年末あたりから言われ出している表現手法です。本当にそうなるかどうかは分かりませんが、実験としてiOS用のライブラリ EMTNeumorphicView を作ってみました。ここではその実装アプローチについて書きます。

Neumorphismとは

フラットデザインやMaterial Designから、かつて流行したスキューモーフィックデザインに少し回帰したようなデザインといえばわかりやすいと思います。
EMTNeumorphicView screenshot

詳細は下記にあります。

自分もnoteを書きました。

凸型 (convex) のUIを作成する

Neumorphismのデザインを実現するには、要素に角Rを付けたうえで明暗2色のシャドウを落とさなければなりません。

実装方法はいろいろあると思いますが、EMTNeumorphicViewではシャドウ用のサブレイヤーを生成するカスタムCALayerを作りました。ライブラリではそれをlayerClassにしたUIView、UIButton、UITableViewCellを提供します。

EMTNeumorphicView.swift
    public override class var layerClass: AnyClass {
        return EMTNeumorphicLayer.self
    }

シャドウをCALayerで実装しているため、ボタンの状態変更にはCore Animationが効いています。
toggle buttons

カスタムCALayer内では、次のような感じで要素色、明るいシャドウ、暗いシャドウの3つのサブレイヤーを使っています。

EMTNeumorphicLayer.swift
masksToBounds = false

colorLayer = CALayer()
shadowLayer = EMTShadowLayer()
lightLayer = EMTShadowLayer()

insertSublayer(colorLayer!, at: 0)
insertSublayer(lightLayer!, at: 0)
insertSublayer(shadowLayer!, at: 0)

colorLayer?.masksToBounds = true
shadowLayer?.masksToBounds = false
lightLayer?.masksToBounds = false

明るいシャドウと暗いシャドウのレイヤーに、shadowPathを使って影をつけます。それぞれ左上方向と右下方向にオフセットし、Neumorphismのシャドウを表現します。

ShadowLayer.swift
let cornerRadius: CGFloat = 24
let shadowRadius: CGFloat = 8
let offsetWidth: CGFloat = shadowRadius / 2
let cornerRadii: CGSize = CGSize(width: cornerRadius - offsetWidth, 
                                 height: cornerRadius - offsetWidth)

var shadowX: CGFloat = offsetWidth
var shadowY: CGFloat = offsetWidth

// reverse offset direction
if mode == isLightSide {
    shadowX *= -1
    shadowY *= -1
}

// add shadow
let shadowBounds = bounds
let path = UIBezierPath(roundedRect: shadowBounds.insetBy(dx: offsetWidth, dy: offsetWidth),
                        byRoundingCorners: [.topLeft, .topRight, .bottomLeft, .bottomRight],
                        cornerRadii: cornerRadii)
shadowPath = path.cgPath
shadowOffset = CGSize(width: shadowX, height: shadowY)

凹型 (concave) のUIを作成する

凹型はトグルボタンやテーブルなどを表現するのに向いていますが、明暗2色をインナーシャドウで表現しなければならないため、凸型よりもずっと実装が面倒です。

要点は次のような感じです

  • 明暗2色のシャドウを2つのレイヤーで表現するのは凸型と同じ
  • 必要な部分のみ表示されるようにマスクをかけ、明暗が綺麗に合成されるようにする

ポイントとなる部分のみ説明します。まずインナーシャドウを作成します。
EMTNeumorphicViewでは、要素の形状に沿った中空のUIBezierPathを作成し、それをレイヤーのshadowPathとして使うことでインナーシャドウを描画しています。

ShadowLayer.swift
masksToBounds = true
cornerRadius = 24

let gap: CGFloat = 1
let cornerRadii = CGSize(width: cornerRadius + gap, height: cornerRadius + gap)
let innerRadius = cornerRadius - gap
let cornerRadiiInner = CGSize(width: innerRadius, height: innerRadius)

let outerPath = UIBezierPath(roundedRect: bounds.insetBy(dx: -gap, dy: -gap),
                             byRoundingCorners: corners,
                             cornerRadii: cornerRadii)
let innerPath = UIBezierPath(roundedRect: bounds.insetBy(dx: gap, dy: gap),
                             byRoundingCorners: corners,
                             cornerRadii: cornerRadiiInner).reversing()

outerPath.append(innerPath)
shadowPath = path.cgPath

問題は明暗2つのシャドウの合成です。凸型の場合はシャドウの重なる部分は要素の後ろ側に隠れてしまうので合成する必要はありませんでした。
凹型の場合、明暗が重なって入れ替わる部分(要素の右上と左下の角R部分)がうまく合成されるように個別にマスクをかけます。
マスク領域を説明するために色をつけると次のようになります。マゼンタがダークシャドウ用、シアンがライトシャドウ用です。
mask area

マスクを生成するためのカスタムレイヤーを作り、drawメソッド内でマスク領域を描画します。
以下はライトシャドウの右上の角R部分をマスクするためのコードです(黒→透明色のグラデーションを右下→左上へ描画)。
ダークシャドウの場合は同じ領域をグラデーションの方向を逆にして描画します。

GradientMaskLayer.swift
override func draw(in ctx: CGContext) {
    // top-right corner
    let cornerRect = CGRect(x: frame.size.width - cornerRadius,
                            y: 0, 
                            width: cornerRadius, 
                            height: cornerRadius)

    let bottomRight = CGPoint(x: cornerRect.maxX, y: cornerRect.maxY)

    guard let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
                                    colors: [UIColor.black.cgColor, UIColor.clear.cgColor] as CFArray,
                                    locations: [0, 1]) else { return }
    ctx.saveGState()
    ctx.addRect(cornerRect)
    ctx.clip()
    ctx.drawLinearGradient(gradient, start: bottomRight, end: cornerRect.origin, options: [])
    ctx.restoreGState()

まとめ

もっといい方法がある気もしますがとりあえずこんな感じです。

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

macOSアプリ用の設定ウィンドウの作成方法

概要

  • macOSアプリを作っている中で、環境設定ウィンドウどうやって作ろうかなあ…と思っていた矢先、タイムラインに天啓が舞い降りました。
  • @1024jp さんがご丁寧にGitHubにサンプルを公開してくださっています、これを参考に私も実装してみました。
    • Swiftの書き方を含めて非常に参考になるのでおすすめです
  • Generalでメッセージの変更、Advancedでフォントの外観を変更できるようになっています。

-w799

-w859

  • フォントパネルによるフォントの変更取得も、実装例がなかなか見つからず苦労したのでよければ参考にまで。

GitHub

参考

環境設定ウィンドウの作成

UserDefaults(環境設定値の保存に使用)

FontPanel

ざっくりコード解説

Main.storyboard

  • Main.storyboardからPreferences.storyboardに接続するため、Storyboard Referenceを作成します

-w458

  • Preferences...にバインドします

-w543

  • Storyboardの名前を指定します

-w496

Preferences.storyboard

WindowController

  • WindowControllerを配置して、デフォルトでついてくるViewControllerは削除します
  • 後で作るPreferencesTabViewControllerをコンテンツにします
  • 下記にチェック

-w1306

PreferencesWindow

  • WindowController内のwindowには、カスタムクラスPreferencesWindow.swiftを指定します
  • NSPanelなのでEscキーで閉じることが可能です

-w1309

  • 最小化とリサイズを無効にします

-w1302

  • 設定画面は画面中央に表示します

-w1307

PreferencesTabViewController

  • 下記を配置します

-w447

  • PreferencesTabViewControllerクラスとします

-w1310

  • 下記を設定
  • (設定パネルがうまくリサイズしてくれないことが結構ありました…。IBが怪しい動きをしている?ので、項目へのバインドを再度貼り直すなどしました。)

-w1320

  • タブ項目をそれぞれ設定

-w1327

-w1310

設定項目の画面

  • 実際に設定画面として表示する画面はここ
  • カスタムクラスを設定して、コード内にそれぞれの画面の処理を書きます
  • 変更内容は今回はNotificationを使用しています
  • 今回のような1対1のものならCocoa Bindingを使用すればグルーコードが減って便利だと思います。

-w1309

  • Opacity(0~100%)にはNumber Formatterを使用しています。
  • 後ろに%を勝手につけてくれたり、少数への変換も不要で便利ですね。

-w1324

ViewController.swift

let advancedPreferences = AdvancedPreferences()
let generalPreferences = GeneralPreferences()
  • 設定ウィンドウで変更した値はUserDefaultsを介して取得しています
  • この作りだったら変数ではなくてシングルトンにしたほうが良いのかな?
// 設定ウィンドウからの通知を受け取る設定
let notificationNames = [Notification.Name(rawValue: "AdvancedPreferencesChanged"),
                         Notification.Name(rawValue: "GeneralPreferencesChanged")]

for notificationName in notificationNames {
    NotificationCenter.default.addObserver(forName: notificationName,
                                           object: nil,
                                           queue: nil) {
                                            (notification) in
                                            self.updateOutputTextField()
    }
}
  • 設定ウィンドウの変更はNotificationで監視します。
outputTextField.wantsLayer = true   // for changing opacity
(...)
outputTextField.layer?.opacity = 0.1
  • NSTextFieldの透明度の設定をする部分
  • NSColoralphaを変更する手もありますが、絵文字が反映されない問題があります。
  • なのでNSTextFieldlayeropacityに設定しています

PreferencesWindow.swift

selectedTabViewItemIndex
override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
    switch menuItem.action {
    case #selector(toggleToolbarShown(_:))?:
        return false
    default:
        return super.validateMenuItem(menuItem)
    }
}
  • デフォルトの場合、下記の用にツールバーが隠すことができてしまいます
  • そこで上記のコードにより、Hide toolbarを選択できないようにしています
  • コードの動きとしては...
    • PrefecencesWindowのメニューアイテムを表示する際に呼ばれて、
    • メニューアイテムのアクションがtoggleToolbarShownの場合に
    • そのアイテムをdisableにする

-w662

  • (その他はコードとコードコメントを参照ください…)

参考

  • validateMenuItem:
    • > Implemented to override the default action of enabling or disabling a specific menu item.

PreferencesTabViewController.swift

private func switchPane(to tabViewItem: NSTabViewItem) {
  • この関数はウィンドウをリサイズするために定義されています
newFrame.origin.y += window.frame.height - newFrame.height    // タイトルバーの位置を変えないようにするための処理
  • 例えば新しい項目に切り替えることでサイズが小さくなる場合、通常ではヘッダー(:=タイトルバー + ツールバー)が下がる形になります。
    • macOSでは左下を原点としているためですね
  • そこで下図の通り計算をして、原点のY座標を上に移動させることで、ヘッダーの位置をそのままにリサイズを行っています

GeneralPreferences.swift

  • General項目の設定値を扱うための、データクラスです
enum UserDefaultsKey: String
    case message
}
  • UserDefault用のキーはenumで宣言しています

GeneralPreferencesViewController.swift

  • General項目のビューを制御するクラスです
NotificationCenter.default.post(name: Notification.Name(rawValue: "GeneralPreferencesChanged"), object: nil)
  • 上記の通り、Notificationによりメインの画面に対して、設定値の変更トリガを引いています。

AdvancedPreferences.swift

  • GeneralPreferences.swiftと同様、Advanced項目の設定値を扱うための、データクラスです
var font: NSFont {
    get {
        guard let name = UserDefaults.standard.object(forKey: UserDefaultsKey.fontName.rawValue) as? String else {
            return NSFont.systemFont(ofSize: NSFont.systemFontSize)
        }

        let size = CGFloat(UserDefaults.standard.float(forKey: UserDefaultsKey.fontSize.rawValue)) // 登録されていないときは…?
        guard let font = NSFont(name: name, size: size) else {
            return NSFont.systemFont(ofSize: NSFont.systemFontSize)
        }
        return font
    }

    set(font) {
        UserDefaults.standard.set(font.fontName, forKey: UserDefaultsKey.fontName.rawValue)
        UserDefaults.standard.set(Float(font.pointSize), forKey: UserDefaultsKey.fontSize.rawValue)
    }
}  
  • UserDefaultsに保存する際は、NSFontではなく、フォント名とフォントサイズに分けています

AdvancedPreferencesViewController.swift

  • Advance項目のビューを制御するクラスです

設定画面を閉じる際にフォントパネルも閉じる

override func viewDidDisappear() {
    let panel = NSFontManager.shared.fontPanel(true)
    panel?.close()
}
  • フォントパネルが残ったまま設定ウィンドウを閉じると困るので、一緒に閉じるように設定します

フォントパネルの表示と変更内容の受け取り

@IBAction func showFontPanel(_ sender: Any) {
    let fontManager = NSFontManager.shared
    fontManager.target = self
    let panel = fontManager.fontPanel(true)
    panel?.orderFront(self)
    panel?.isEnabled = true // trueをセットすると使用可能になります(今回は無くても良い?)
}
  • フォントパネルで変更があった際にactionを送る相手を、targetで自身に設定します
  • actionのデフォルト名はfunc changeFont(_ sender: NSFontManager?) {です。
extension AdvancedPreferencesViewController : NSFontChanging {
    func changeFont(_ sender: NSFontManager?) {
        guard let fontManager = sender else {
            return
        }
        let newFont = fontManager.convert(advancedPreferences.font)
        advancedPreferences.font = newFont
        fontNameTextField.stringValue = String(format: "%@ %d", advancedPreferences.font.fontName, Int(advancedPreferences.font.pointSize))
        advancedPreferencesChanged()
    }
}
  • 上記の通り定義します。
  • 表示のフォーマットはCotEditorに習いました。

-w560

カラーパネルによる色の変更取得

@IBAction func changeFontColor(_ sender: Any) {
    guard let colorWell = sender as? NSColorWell else {
        return
    }
    if (colorWell.identifier!.rawValue == "FontColorWell") {
        advancedPreferences.fontColor = colorWell.color
    } else if (colorWell.identifier!.rawValue == "StrokeColorWell") {
        advancedPreferences.strokeColor = colorWell.color
    }
    advancedPreferencesChanged()
}
  • FontPanelとは打って変わって、簡単にIBActionで取得できます。
  • 処理が同じなので、同じアクションに紐づけてidentifierで分岐させています。

つまづきポイント

フォントパネルの変更後にchangeFontが呼ばれない

概要

  • NSTextFieldがなければうまくいくので、なぜかなとハマりました。

-w948

解決方法

  • NSFontManagertargetに自身を指定します
let fontManager = NSFontManager.shared
fontManager.target = self

詳細

  • NSTextFieldが無い場合は、Responder chainchangeFontが呼べていた。
  • しかしNSTextFieldがある場合、NSViewでResponder chainが止まってしまい、NSViewControllerまで届かない。
    • 下記メソッドでResponder chainを確認してみました。
func displayResponderChain(_ sender: NSResponder?) {
    guard let res = sender else {
        return
    }
    print("\(String(describing: res.nextResponder))")
}
print("\(String(describing: sender))")  // フォーカスしているNSTextField
displayResponderChain(strokeWidthTextField)

<NSTextField: 0x105014a90>
Optional(<NSView: 0x608000121400>)
  • そこで明示的にNSFontManager.targetにより、メソッドを呼び出すクラスを指定すると、動作するようになりました。

参考

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