2022年05月18日, 編集履歴
Silver Baton ver. 1.4.1をリリースしました
Silver Baton ver. 1.4.1をリリースしました。手元のトラックパッド/マウス操作でMusic.appを操作できるパネルを表示するmacOS用アプリです。
軽微な修正のみのヴァージョンです。どうぞよろしく。
2022年05月18日, 編集履歴
Silver Baton ver. 1.4.1をリリースしました。手元のトラックパッド/マウス操作でMusic.appを操作できるパネルを表示するmacOS用アプリです。
軽微な修正のみのヴァージョンです。どうぞよろしく。
2021年12月21日, 編集履歴
SwiftUIでDocument-Based Appな画像閲覧アプリ習作の覚書その4。
このシリーズの他のブログは、
作成したプロジェクトはGitHubで公開している。
ビルド環境は、
である。
前回はツールバーを実装した。今回はメニューコマンドを実装する。
ここで、今回のキモとなる.focusedSceneValue
は、本来はmacOS/iOS共通して使えるものであるが、iOS 15.2のiPhone 8実機およびiPadシミュレータでは動作しなかった。macOSは12.0.1までは動作しなかったが、12.1で動作するようになった。
.commands
、CommandMenu
、CommandGroup
メニューコマンドを実装するには、WindowGroup
シーンやDocumentGroup
シーンに対して.commands
を指定し、CommandMenu
あるいはCommandGroup
を使ってメニュー構造を作る。
@main
struct ImageViewerSwiftUIApp: App {
var body: some Scene {
DocumentGroup(viewing: ImageDocument.self) { file in
ContentView(document: file.document)
}
.commands {
CommandMenu("MyMenu") {
Button("Action 1") { print("action 1") }
}
CommandGroup(after: .toolbar) {
Button("Action 2") { print("action 2") }
}
}
}
}
CommandMenu
はメニューバーのトップレベルに新しいメニューを作り、CommandGroup
は既存のメニューの中にメニュー項目を作る。
ここで、マルチウィンドウなDocument-Based Appのメニューコマンドを実装するとき、複数存在しうるウィンドウ(ドキュメント)のどれに対してのアクション/操作なのかを指定したい。メニューコマンドの実装部分はドキュメントオブジェクトが存在するスコープの外なので、そのままでは対象のドキュメントを指定できない。
そこで.focusedSceneValue(_:_:)
を使う。
.focusedSceneValue(_:_:)
、FocusedValueKey
、FocusedValues
.focusedSceneValue(_:_:)
はシーンの切り替わりに応じて、通常はアクティブなウィンドウの切り替わりに応じて、何らかの値を公開できる機能である。今回のDocument-Based Appな画像閲覧アプリの場合、シーン(ウィンドウ)とドキュメントオブジェクトが一対一対応しているので、シーン(ウィンドウ)の切り替わりに応じて対応するドキュメントオブジェクトを.focusedSceneValue(_:_:)
で公開すると良い。
.focusedSceneValue(_:_:)
を使うには、準備としてFocusedValueKey
プロトコルに準拠した構造体の定義とFocusedValues
構造体の拡張が必要となる。
struct FocusedSceneDocumentKey: FocusedValueKey {
typealias Value = ImageDocument
}
extension FocusedValues {
var focusedSceneDocument: ImageDocument? {
get { self[FocusedSceneDocumentKey.self] }
set { self[FocusedSceneDocumentKey.self] = newValue }
}
}
ほとんど定型文的な記述になる。
FocusedValueKey
プロトコルに準拠した構造体では公開したい値の型を指定する。ここでのImageDocument
はReferenceFileDocument
な参照型のドキュメントオブジェクトである。この構造体の型を次の計算型プロパティで使用する。
FocusedValues
構造体の拡張では計算型プロパティを定義する。プロパティの型は公開したい型のオプショナル型になる。このプロパティ名が後述の.focusedSceneValue(_:_:)
で使用するkey path名となる。
これで準備完了。シーン配下のビューで.focusedSceneValue(_:_:)
を使う。
struct ImageViewerSwiftUIApp: App {
var body: some Scene {
DocumentGroup(viewing: ImageDocument.self) { file in
ContentView(document: file.document)
.focusedSceneValue(\.focusedSceneDocument, file.document)
}
}
}
第一引数にはFocusedValues
構造体の拡張で作ったプロパティ名をkey path名として指定し、第二引数で公開したい値を指定する。
公開された値を使用するときは@FocusedValue
プロパティラッパを使用する。
@FocusedValue(\.focusedSceneDocument) var document
@FocusedValue
なプロパティの宣言には、FocusedValues
構造体で作ったプロパティ名をkey path名として指定する。@FocusedValue
なプロパティは、.focusedSceneValue(_:_:)
で公開されているドキュメントオブジェクト、あるいはnil
が入っているオプショナル型である。
今回はメニューコマンドの実装を外部化する。通常のビューを外部化するときはView
プロトコルが用いられるが、メニューコマンドの場合はCommands
プロトコルを使用する。
struct ImageViewerSwiftUIApp: App {
var body: some Scene {
DocumentGroup(viewing: ImageDocument.self) { file in
ContentView(document: file.document)
.focusedSceneValue(\.focusedSceneDocument, file.document)
}
.commands {
ZoomCommands()
}
}
}
struct ZoomCommands: Commands {
@FocusedValue(\.focusedSceneDocument) var document
var body: some Commands {
CommandGroup(after: .toolbar) {
Button("Actual Size") {
document?.resetViewSize(animate: true)
}
.keyboardShortcut(KeyEquivalent("0"))
.disabled(document == nil)
Button("Zoom In") {
document?.scaleViewSize(2.0, animate: true)
}
.keyboardShortcut(KeyEquivalent("+"))
.disabled(document == nil)
Button("Zoom Out") {
document?.scaleViewSize(0.5, animate: true)
}
.keyboardShortcut(KeyEquivalent("-"))
.disabled(document == nil)
Divider()
}
}
}
前回までで実装した閲覧中の画像の拡大・縮小表示を行う処理を実装した。
既存のViewメニューの中に入れたかったので、CommandGroup(after: .toolbar){ ... }
とし、Viewメニューの中のツールバー関連メニューの次に表示させるようにした。
キーボードショートカットやメニュー項目の有効・無効化処理も入れている。メニューに境界線を引きたいときはDivder()
を使う。
この.focusedSceneValue
の方法はiOSでも使えるはずなのだが、iOS 15.2の段階では@FocusedValue
なプロパティが常にnil
になって正常に動作しない。macOSでも12.0.1では同様だった。本来はmacOS 12.0、iOS 15.0から使えていなければならなかった。
また、SwiftUIがまだまだ過渡期ゆえだとは思うが、.focusedSceneValue(_:_:)
ではなく、アクティブなドキュメントを簡便に参照できるような仕組みは最初から入っていて欲しかった。定型文的な準備が必要なのはなんか変だ。
2021年12月03日, 編集履歴
Murasaki ver. 2.4.1をリリースしました。macOS用のEPUBリーダアプリです。
前回からの主な変更点は、
です。
macOS 10.15 Catalina辺りから、Murasaki同梱のEPUB用Spotlight/Quick Lookプラグインが動かなくなっていました。前回のリリースでプラグインの同梱をやめたのは、これが理由です。
従前、EPUBにはorg.idpf.epub-container
というUTI(ファイル形式を同定する識別子)が定義されていました。プラグインの開発ではこのUTIを用いてファイル形式の判別等を行います。しかし、いつの頃からか、com.apple.ibooks.epub
なるUTIが定義されており、EPUBファイル用のUTIとしてこちらが使われるようになってしまっており、プラグインが動作しない状況になっていました。Murasaki同梱のSpotlightプラグインが動かなくなっていた原因はこれです。
今回、UTIのバッティング問題に気づいたことで、Spotlightプラグインを修正できました。
Quick Lookプラグインについては、macOSには最初からEPUB用Quick Lookプラグインが組み込まれており、アプリ同梱プラグインより優先して読み込まれるようになっていたことが原因です。通常は、とあるファイル形式に対応したプラグインがOSに組み込まれていたとしても、アプリ同梱のプラグインや/Users/***/Library/QuickLook
にインストールされたものが優先される仕様のはずなのです。
しかしながら、macOS 12 MontereyではQuick Lookプラグインの優先読み込み問題が知らぬ間に解決されていたことが解りました。Appleにバグレポートを投げても梨の礫だったこともあって、腑に落ちぬところはありますが、ともあれ解決したのならヨシ!
とは言え、手元に環境がないので解らないのですが、おそらくmacOS 11以前ではQuick Lookプラグインの問題は解決していないと思うので、今回からmacOS 12以降のみをサポートすることにします。
2021年09月06日, 編集履歴
SwiftUIの非Document AppなmacOSアプリにおいて、ファイルを開く処理を実装する。今回は以下の3つの手法を試した。
NSOpenPanel
fileImporter()
onDrop()
成果物をGitHubに公開している。
ビルド環境は、
である。
NSOpenPanel
まずは、これまで通りNSOpenPanel
を使う方法から。以下のようなビューがあったとする。
Image
とButton
が並んでおり、Button
押下でNSOpenPanel
を表示、選択された画像をImage
に表示する場合を考える。対応する画像形式はPNGとJPEG、Image
のサイズは一定とする。
import SwiftUI
import UniformTypeIdentifiers
struct NSOpenPanelView: View {
@State private var image: NSImage? = nil
var body: some View {
VStack {
Image(nsImage: image ?? NSImage())
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 500, height: 500)
Button("Open") {
let openPanel = NSOpenPanel()
openPanel.allowsMultipleSelection = false
openPanel.canChooseDirectories = false
openPanel.canChooseFiles = true
openPanel.allowedFileTypes = [UTType.png.identifier, UTType.jpeg.identifier]
if openPanel.runModal() == .OK {
guard let url = openPanel.url,
let newImage = NSImage(contentsOf: url)
else { return }
image = newImage
}
}
.padding()
}
}
}
Button
のaction
コールバック内でNSOpenPanel
を生成し、runModal()
してやればよい。その返り値が.OK
の場合はファイル選択が成功し、NSOpenPanel
のurl
プロパティに選択されたファイルのURLが入っている。
対応形式にUTIが求められるときはUTType
型を使うとよい。UTType
は新しい型で、UTIを使うAPIによってはUTType
型を求められたり、String
型で求められたりが混在している。String
型を求められているAPIならUTType
のidentifier
を使う。UTType
型を使うときはimport UniformTypeIdentifiers
が必要である。後の項でも同様。
fileImporter()
SwiftUIらしいやり方として、fileImporter(isPresented:allowedContentTypes:onCompletion:)
やそのヴァリエーションを使う方法がある。前項と同様のビューがあったとする。
今度もButton
押下でファイル選択のパネルを出す。
import SwiftUI
import UniformTypeIdentifiers
struct FileImporterView: View {
@State private var image: NSImage? = nil
@State private var importerPresented = false
var body: some View {
VStack {
Image(nsImage: image ?? NSImage())
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 500, height: 500)
Button("Open") {
importerPresented = true
}
.padding()
}
.fileImporter(isPresented: $importerPresented, allowedContentTypes: [.png, .jpeg]) { result in
switch result {
case .success(let url):
guard let newImage = NSImage(contentsOf: url) else { return }
image = newImage
case .failure:
print("failure")
}
}
}
}
fileImporter()
は、その第1引数isPresented
に指定する@State
なプロパティがtrue
になったときにファイル選択パネルを表示する。Button
押下時のaction
としてそのプロパティをtrue
にする処理を入れてやればよい。isPresented
に指定したプロパティは、ファイル選択パネルでの操作が終わったときに自動的にfalse
に戻る。
fileImporter()
のコールバックの引数はResult<URL, Error>
型なのでswitch
文でURLを取り出し、画像を生成する。
ファイル選択パネルでキャンセルしても.failure
にはならないようである。
onDrop()
画像のドラッグ&ドロップも試してみる。以下のようなImage
が全面に配置されたビューがあったとする。
ドラッグ&ドロップによってImage
に画像を表示する場合を考える。また、ドラッグ中に画像がビューの領域に入った場合は、Text
をオーバーレイ表示させるとする。
import SwiftUI
import UniformTypeIdentifiers
struct DropView: View {
@State private var image: NSImage? = nil
@State private var isDropTargeted = false
var body: some View {
ZStack {
Image(nsImage: image ?? NSImage())
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 500, height: 500)
if isDropTargeted {
Rectangle()
.fill(Color(.windowBackgroundColor))
.opacity(0.8)
.overlay(
Text("Drop Here")
.font(.system(size: 64).bold())
)
}
}
.onDrop(of: [.png, .jpeg, .url, .fileURL], isTargeted: $isDropTargeted) { providers in
guard let provider = providers.first
else { return false }
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { data, error in
guard let imageData = data as? Data,
let newImage = NSImage(data: imageData)
else { return }
image = newImage
}
}
else if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { data, error in
guard let urlData = data as? Data,
let url = URL(dataRepresentation: urlData, relativeTo: nil),
let newImage = NSImage(contentsOf: url)
else { return }
image = newImage
}
}
return true
}
}
}
ドロップに対応させるにはonDrop(of:isTargeted:perform:)
やそのヴァリエーションを使う。
第1引数で対応形式を指定するが、今回は[.png, .jpeg, .url, .fileURL]
とした。
他のアプリから画像データそのものがドロップされた場合.png
や.jpeg
に対応する。Music.appの「情報を見る」の「アートワーク」タブから画像をドロップした場合などがこれである。
Safariで画像のURLを開き、アドレス欄のファビコン部分をドラッグ&ドロップした場合は.url
に対応する。このとき、データの取得にインターネットアクセスを必要とするので、App Sandboxの設定で「Outgoing Connections (Client)」のチェックを入れておく。
Finderから画像ファイルをドロップした場合や、プレビューアプリのタイトルバー部分のアイコンをドロップした場合は.fileURL
に対応する。
また、Safariで表示されている画像そのものをドロップした場合は、.png
等の画像形式と.url
形式に両対応する。
ドロップされたデータの読み込みはloadItem(forTypeIdentifier:options:completionHandler:)
やそのヴァリエーションを使う。今回は.png
と.jpeg
はその親形式であるUTType.image
としてまとめて処理し、.fileURL
は親形式の.url
とまとめて処理した。.url
と.fileURL
に関して、今回は読み込み後の形式チェックはしていないので、PNG/JPEG以外でもNSImage
が対応している形式であれば読み込みが成功するようになっている。
onDrop(of:isTargeted:perform:)
の第2引数isTargeted
に@State
なプロパティを指定しておけば、ドロップ領域に入った場合にtrue
、外に出たときにfalse
が入るので、オーバレイ表示するビューの表示・非表示の条件に用いた。
(たぶん)macOS 13から.onDrop()
とloadItem()
の挙動が変わって、上記コードでは画像読み込みができなくなった。macOS 13に対応したコードを以下に示す。
//(省略)
.onDrop(of: [.image], isTargeted: $isDropTargeted) { providers in
guard let provider = providers.first else { return false }
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
provider.loadItem(forTypeIdentifier: UTType.image.identifier) { item, error in
guard error == nil,
let url = item as? URL,
let loadedImage = NSImage(contentsOf: url)
else { return }
image = loadedImage
}
}
return true
}
これで、
等々からのドラッグ&ドロップで画像を読み込み、表示することができる。
ただし、Safariで画像そのもののURLを開いて、アドレス欄のURLをドラッグ&ドロップした場合は読み込めない。この場合の読み込み方法は謎である。
また、loadItem()
の引数でUTType.image.identifier
を指定しているのに、取得できるのがURL
型になるのも変な感じがする。直感的にはData
型になりそうなものだが……(NSImage(data:)
で画像データを作れるイメージ)。
今回は説明のためにそれぞれの読み込み方法を別々のビューに実装したが、実際に使用する場合は前二者の方法とonDrop()
の方法を組み合わせたり、メニューからの読み込み処理の呼び出しを実装したりするとよい。
2021年08月29日, 編集履歴
ウィンドウ下部にマウスを持っていくと、メインの領域の上に重なるように表示される操作UIを実装する。こんな感じ。
動画プレイヤアプリの再生ボタン等がこんな感じで実装されていることがよくある。本項ではこれを「オーバレイ操作UI」と呼ぶことにする。
成果物をGitHubに公開する。
ビルド環境は、
である。
一例として以下のようなビューがあるとする。
struct ContentView: View {
var body: some View {
Image(systemName: "star")
.font(.system(size: 500))
}
}
オーバレイ操作UIに入れるUI部品を今回はSlider
にするとして、そのスライダ値によって星の色を変える場合を考える。
struct ContentView: View {
@State private var hue = 0.5
var body: some View {
ZStack {
Image(systemName: "star")
.font(.system(size: 500))
.foregroundColor(Color(hue: self.hue, saturation: 1.0, brightness: 1.0))
VStack {
Spacer()
Slider(value: self.$hue, in: 0...1) {
Text("Hue:")
}
.frame(width: 250)
.padding()
.background(
Capsule()
.fill(Color(.windowBackgroundColor))
.shadow(radius: 5)
)
}
.padding(.bottom, 16)
}
}
}
全体をZStack
に入れ、元のImage
と目的のUI部品を併置する。今回はオーバレイ操作UIをウィンドウ下部に配置したいので、VStack
とSpacer
を組み合わせた。また、UI部品の背景を描画するため、background
とCapsule
を使用した。メインの領域と境界をわかりやすくするため、shadow
も加えた。
onHover
で表示・非表示の切り替えこれではオーバレイ操作UIが表示しっぱなしなので、領域にマウスが入ったときにだけ表示されるようにする。
struct ContentView: View {
@State private var hue = 0.5
@State private var isHover = false
var body: some View {
ZStack {
Image(systemName: "star")
.font(.system(size: 500))
.foregroundColor(Color(hue: self.hue, saturation: 1.0, brightness: 1.0))
VStack {
Spacer()
Slider(value: self.$hue, in: 0...1) {
Text("Hue:")
}
.frame(width: 250)
.padding()
.background(
Capsule()
.fill(Color(.windowBackgroundColor))
.shadow(radius: 5)
)
.onHover { hovering in
withAnimation {
self.isHover = hovering
}
}
}
.padding(.bottom, 16)
.opacity(self.isHover ? 1.0 : 0.0)
}
}
}
onHover
はマウスがその領域に出入りするときに呼ばれる。クロージャの引数hovering
に出入りの状態がBool
型で入っているので、@State
なプロパティisHover
に代入する。isHover
はオーバレイ操作UI全体を包むVStack
のopacity
の条件として用いる。isHover
代入時にwithAnimation
を用いることで、ふわっとした表示・非表示の切り替えがされるようになる。
これで、領域内へマウスが入ったときにだけオーバレイ操作UIが表示されるようになるが、スライダ操作中に領域外へ出てしまうと非表示になってしまう問題がある。
少なくともmacOS 13.2.1では、.opacity()
と.onHover()
の順番が重要で、.opacity()
を先に記述しないと、.onHover()
が反応しなかったり、反応する領域が狭く変化してしまうという問題がある(参照:SwiftUIの .opacity() と .onHover() は順番が重要)。
したがって、前記コードの.opacity()
を.onHover()
の前に移動させなければならない(後記コードも同様)。
スライダ操作中は非表示にならないよう工夫をする。
struct ContentView: View {
@State private var hue = 0.5
@State private var isHover = false
@State private var isEditing = false
var body: some View {
ZStack {
Image(systemName: "star")
.font(.system(size: 500))
.foregroundColor(Color(hue: self.hue, saturation: 1.0, brightness: 1.0))
VStack {
Spacer()
Slider(value: self.$hue, in: 0...1, onEditingChanged: { editing in
withAnimation {
self.isEditing = editing
}
}, label: {
Text("Hue:")
})
.frame(width: 250)
.padding()
.background(
Capsule()
.fill(Color(.windowBackgroundColor))
.shadow(radius: 5)
)
.onHover { hovering in
withAnimation {
self.isHover = hovering
}
}
}
.padding(.bottom, 16)
.opacity(self.isHover || self.isEditing ? 1.0 : 0.0)
}
}
}
Slider
の作成をinit(value:in:onEditingChanged:label:)
を使うようにした。増えたonEditingChanged
の部分は、スライダ操作開始でクロージャ引数にtrue
が、操作終了でfalse
が入る。これを@State
なプロパティisEditing
に代入する。オーバレイ操作UI全体のVStack
に付けたopacity
の条件にisEditing
も加えることで、スライダ操作中は非表示にならなくなる。
さらに一工夫。初見ではオーバレイ操作UI自体の存在が認知されないので、最初の数秒間はオーバレイ操作UIが表示されるようにする。
struct ContentView: View {
@State private var hue = 0.5
@State private var isHover = true
@State private var isEditing = false
@State private var timer: Timer? = nil
var body: some View {
ZStack {
Image(systemName: "star")
.font(.system(size: 500))
.foregroundColor(Color(hue: self.hue, saturation: 1.0, brightness: 1.0))
VStack {
Spacer()
Slider(value: self.$hue, in: 0...1, onEditingChanged: { editing in
withAnimation {
self.isEditing = editing
}
}, label: {
Text("Hue:")
})
.frame(width: 250)
.padding()
.background(
Capsule()
.fill(Color(.windowBackgroundColor))
.shadow(radius: 5)
)
.onHover { hovering in
self.timer?.invalidate()
self.timer = nil
withAnimation {
self.isHover = hovering
}
}
}
.padding(.bottom, 16)
.opacity(self.isHover || self.isEditing ? 1.0 : 0.0)
}
.onAppear {
self.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in
withAnimation {
self.isHover = false
}
self.timer?.invalidate()
self.timer = nil
}
}
}
}
isHover
プロパティの初期値をtrue
にし、ビュー表示時にはオーバレイ操作UIが出ている状態にする。それと同時に、ビュー全体を包むZStack
にonAppear
を付けて、その中で数秒後にisHover
をfalse
に切り替えるTimer.scheduledTimer
を作った。
また、タイマ発火前にオーバレイ操作UIの領域に入り、領域にいるままにタイマが発火すると、領域内にマウスがあるのに非表示なってしまう。タイマ発火前に領域に入った場合は、タイマを破棄する処理を入れる。
最後に、使い回しができるようにコンテナビューとして切り出す。
struct ContentView: View {
@State private var hue = 0.5
@State private var isEditing = false
var body: some View {
VStack {
OverlayControlContainer(isEditing: self.$isEditing, content: {
Image(systemName: "star")
.font(.system(size: 500))
.foregroundColor(Color(hue: self.hue, saturation: 1.0, brightness: 1.0))
}, overlayControl: {
Slider(value: self.$hue, in: 0...1, onEditingChanged: { editing in
withAnimation {
self.isEditing = editing
}
}, label: {
Text("Hue:")
})
.frame(width: 250)
})
}
}
}
struct OverlayControlContainer<Content: View, OverlayControl: View>: View {
@Binding var isEditing: Bool
@ViewBuilder let content: Content
@ViewBuilder let overlayControl: OverlayControl
@State private var isHover = true
@State private var timer: Timer? = nil
var body: some View {
ZStack {
self.content
VStack {
Spacer()
self.overlayControl
.padding()
.background(
Capsule()
.fill(Color(.windowBackgroundColor))
.shadow(radius: 5)
)
.onHover { hovering in
self.timer?.invalidate()
self.timer = nil
withAnimation {
self.isHover = hovering
}
}
}
.padding(.bottom, 16)
.opacity(self.isHover || self.isEditing ? 1.0 : 0.0)
}
.onAppear {
self.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in
withAnimation {
self.isHover = false
}
self.timer?.invalidate()
self.timer = nil
}
}
}
}
完成!