feed
2024年02月20日, 編集履歴
Tableの基本的な使い方は「SwiftUI Tableの使い方 」を参照。
macOSアプリにSwiftUIのTableを組み込み、副ボタンクリック(右クリック)によるコンテキストメニューをつけた時、テーブル行として表現されているオブジェクトに対して何らかの操作を行うような場合を考える。たとえばリスト表示にしたFinderで、ファイルやフォルダを右クリックしたときにでるコンテキストメニューをイメージしてもらうと良い。
このときTableColumn配下の要素やTableRowに.contextMenu(menuItems:)モディファイアをつけてしまいがちだが、macOSアプリのテーブルに対するコンテキストメニューの自然な挙動を実現するには、Table自体に.contextMenu(forSelectionType:menu:primaryAction:)モディファイアをつけなければならない。
テーブルの選択状態と操作対象
テーブル行をふつうにクリックした場合、行選択の状態となり、行全体にハイライトがつく。行を右クリックした場合、行の縁だけハイライトがつき、コンテキストメニューの起点、操作対象の状態になる。このふたつは別の状態である。選択範囲とコンテキストメニューの操作対象は重なる場合もあれば、異なる場合もある。
コンテキストメニューの操作対象は以下のようになる:
行を選択していない状態から右クリックした場合、操作対象は右クリックした行になる。
行を選択している状態から選択内 の行を右クリックした場合、操作対象は選択範囲の行になる。
行を選択している状態から選択外 の行を右クリックした場合、操作対象は右クリックした行のみ になる。
表内部の行がない余白部分を右クリックした場合、操作対象のオブジェクトはない(親オブジェクトや対象を必要としない処理になる)。
上図のコンテキストメニューの内容から操作対象が変わっていることが解る。
実装
以下のような実装が良いと思う。
import SwiftUI
import UniformTypeIdentifiers
struct Bookmark : Identifiable {
var id = UUID ()
var title : String
var url : URL
}
struct ContentView : View {
@State private var sampleData : [ Bookmark ] = [
. init ( title : "Genji App" , url : URL ( string : "https://genjiapp.com" ) ! ),
. init ( title : "Apple" , url : URL ( string : "https://www.apple.com" ) ! ),
. init ( title : "Google" , url : URL ( string : "https://www.google.com" ) ! )
]
@State private var selectedIDs = Set < Bookmark . ID > ()
var body : some View {
Table ( of : Bookmark . self , selection : $ selectedIDs ) {
TableColumn ( "Title" ) { bookmark in
Text ( bookmark . title )
// .contextMenu {
// Button("ここじゃない") {
// print(bookmark)
// print(selectedIDs)
// }
// }
}
TableColumn ( "URL" ) { bookmark in
Text ( bookmark . url . absoluteString )
}
} rows : {
ForEach ( sampleData ) { bookmark in
TableRow ( bookmark )
// .contextMenu {
// Button("ここでもない") {
// print(bookmark)
// print(selectedIDs)
// }
// }
}
}
. tableStyle ( . bordered )
// ここ!
. contextMenu ( forSelectionType : Bookmark . ID . self ) { clickedRowIDs in
addButton
Button ( "Delete Bookmark" ) {
sampleData . removeAll { clickedRowIDs . contains ( $0 . id ) }
}
}
// ここはテーブル内余白を右クリックしたとき
. contextMenu {
addButton
}
. padding ()
}
var addButton : some View {
Button ( "Add Bookmark" ) {
sampleData . append ( . init ( title : "Microsoft" , url : URL ( string : "https://www.microsoft.com" ) ! ))
}
}
}
TableColumn配下の要素に.contextMenu(menuItems:)モディファイアをつける場合、複数の列があるときはすべてにモディファイアをつけなければならないし、右クリックに反応するのは行の中の文字がある部分だけになる。
TableRowに.contextMenu(menuItems:)モディファイアをつける場合、選択行とコンテキストメニューの操作対象両方を取得できるが、両者が別の型になるし、両者の重なり具合を調べて操作対象を自分で計算する必要がある。
Table自体に.contextMenu(forSelectionType:menu:primaryAction:)モディファイアをつける場合、第2引数menuクロージャの引数として、標準的な操作対象となるオブジェクトのidがSetで取得できるので、その後の処理がストレートに記述できる。
第1引数forSelectionTypeには、テーブル行を表現するオブジェクトを特定するための型を指定する。オブジェクトはIdentifiableプロトコルに適合させているはずなので、idプロパティの型となる。第3引数primaryActionは、行をダブルクリックした時に実行される。
また、ドキュメントには.contextMenu(forSelectionType:menu:primaryAction:)の第2引数menuクロージャの引数が空のSetの場合はテーブルの余白部分を右クリックしたことになるとあるが、macOS 14.3.1とXcode Version 15.2 (15C500b)の環境ではコンテキストメニューが出ない。上のコード例ではTable自体にもうひとつ別の.contextMenu(menuItems:)をつけることで、余白部分を右クリックした場合をカバーしている。
2024年02月18日, 編集履歴
App Sandbox環境下のmacOSアプリにおいて、ローカルファイルへのアクセスには制限が課せられている。
コード内で適当に生成したURLではローカルファイルへアクセスできない。ファイルへのアクセス権を得るには、
NSOpenPanel
fileImporter()モディファイア
ドラッグ&ドロップ
等の限られた方法を取る必要がある。
ただしNSOpenPanel等で取得したURLは、その起動中はURL先のファイルにアクセス可能だが、そのURLを保存しておいたとしても、次の起動時にはアクセスできなくなってしまう。URLへのアクセスを永続化するには、Security-scopedなURL Bookmarkを作らなければならない。
URLからSecurity-scoped Bookmarkの生成
URLのbookmarkData(options:includingResourceValuesForKeys:relativeTo:)関数を使ってSecurity-scoped Bookmark(Data型)を作成する。options引数にはwithSecurityScope を指定。URLへのアクセスが読み込みのみで良い場合は、securityScopeAllowOnlyReadAccess も併用する。
let bookmarkData = try ? url . bookmarkData ( options : [ . withSecurityScope , . securityScopeAllowOnlyReadAccess ], includingResourceValuesForKeys : nil , relativeTo : nil )
Security-scoped BookmarkからURLを生成し、アクセスする
URLのinit(resolvingBookmarkData:options:relativeTo:bookmarkDataIsStale:)関数を使うことで、Security-scoped BookmarkなDataからURLを生成できる。resolvingBookmarkData引数にはSecurity-scoped BookmarkなDataを、options引数にはwithSecurityScopeを指定する。
生成されたURLに実際にアクセスするには、アクセス前にstartAccessingSecurityScopedResource()を実行し、アクセス後にはstopAccessingSecurityScopedResource()を実行する。
var isStale = false
let urlData = // 前項の手法で保存したURLのData
if let url = try ? URL ( resolvingBookmarkData : urlData , options : . withSecurityScope , relativeTo : nil , bookmarkDataIsStale : & isStale ) {
if url . startAccessingSecurityScopedResource () {
// URL先への読み書き処理
url . stopAccessingSecurityScopedResource ()
}
}
URLのinit(resolvingBookmarkData:options:relativeTo:bookmarkDataIsStale:)実行後にisStaleがtrueになっている場合、Bookmark Data作成以降にURL先ファイルの名前が変更されたり、場所が変更されたことを意味する。その場合でも返却されたURLは変更後のものになっており、アクセスは可能だが、新しくSecurity-scoped Bookmarkを生成し直す必要がある。
Security-scoped Bookmarkを再生成するときも、URL先へのアクセスに相当するので、startAccessingSecurityScopedResource()とstopAccessingSecurityScopedResource()が必要になる。
2024年02月17日, 編集履歴
以下のような構造体の配列をSwiftUIのTableビューに表示する場合を考える。
struct Bookmark {
var title : String
var url : URL
}
単純に表形式で表示する
データを単純にTableに表示したいだけの場合、Tableのinit<Data>(Data, columns: () -> Columns)と、TableColumnのinit(Text, content: (RowValue) -> Content)あたりを使う。このとき、表示するデータ構造はIdentifiableプロトコルに適合させる必要があるので、プロパティにidを追加した。
import SwiftUI
struct Bookmark : Identifiable {
var id = UUID ()
var title : String
var url : URL
}
struct ContentView : View {
@State var sampleData : [ Bookmark ] = [
Bookmark ( title : "Genji App" , url : URL ( string : "https://genjiapp.com/" ) ! ),
Bookmark ( title : "Apple" , url : URL ( string : "https://www.apple.com/" ) ! ),
Bookmark ( title : "Google" , url : URL ( string : "https://www.google.com/" ) ! )
]
var body : some View {
Table ( sampleData ) {
TableColumn ( "Title" ) { bookmark in
Text ( bookmark . title )
}
TableColumn ( "URL" ) { bookmark in
Text ( bookmark . url . absoluteString )
}
}
. tableStyle ( . bordered )
. padding ()
}
}
行を選択可能にする
テーブル行を選択可能にする場合、
init<Data>(Data, selection: Binding<Value.ID?>, columns: () -> Columns)(単一選択)
init<Data>(Data, selection: Binding<Set<Value.ID>>, columns: () -> Columns)(複数選択)
あたりを使う。selection引数の型がIDのオプショナルか、IDのSet型かの違い。
struct ContentView : View {
@State var sampleData : [ Bookmark ] = [
Bookmark ( title : "Genji App" , url : URL ( string : "https://genjiapp.com/" ) ! ),
Bookmark ( title : "Apple" , url : URL ( string : "https://www.apple.com/" ) ! ),
Bookmark ( title : "Google" , url : URL ( string : "https://www.google.com/" ) ! )
]
@State private var selectedID : Bookmark . ID ?
var body : some View {
Table ( sampleData , selection : $ selectedID ) {
TableColumn ( "Title" ) { bookmark in
Text ( bookmark . title )
}
TableColumn ( "URL" ) { bookmark in
Text ( bookmark . url . absoluteString )
}
}
. tableStyle ( . bordered )
. padding ()
}
}
行クリック時に選択可能になった。
ドラッグ&ドロップで並べ替え
テーブル行をドラッグ&ドロップで並べ替え可能にするには、init(of: Value.Type, selection: Binding<Set<Value.ID>>, columns: () -> Columns, rows: () -> Rows)あたりを使う。先ほどまでのイニシャライザとは使い方がちょっと違う。第一引数に表示するデータの型、最後のrows引数にはTableRowで表示データを供給する。以下のような感じ。
struct ContentView : View {
@State var sampleData : [ Bookmark ] = [
Bookmark ( title : "Genji App" , url : URL ( string : "https://genjiapp.com/" ) ! ),
Bookmark ( title : "Apple" , url : URL ( string : "https://www.apple.com/" ) ! ),
Bookmark ( title : "Google" , url : URL ( string : "https://www.google.com/" ) ! )
]
@State private var selectedID : Bookmark . ID ?
var body : some View {
Table ( of : Bookmark . self , selection : $ selectedID ) {
TableColumn ( "Title" ) { bookmark in
Text ( bookmark . title )
}
TableColumn ( "URL" ) { bookmark in
Text ( bookmark . url . absoluteString )
}
} rows : {
ForEach ( sampleData ) { bookmark in
TableRow ( bookmark )
}
}
. tableStyle ( . bordered )
. padding ()
}
}
ここまでは、まだデータの表示と行選択ができるだけで、前項までとは別のイニシャライザを使ったに過ぎない。行をドラッグ&ドロップできるようにするには、draggableとdropDestinationモディファイアを使う。この時、表示データはTransferableプロトコルに適合させる必要があり、そのためにはCodableプロトコルにも適合させておくとかんたんになる。また、ドラッグ&ドロップさせるデータのためのカスタムUTTypeも用意する。以下のような感じ。
import SwiftUI
import UniformTypeIdentifiers
extension UTType {
static let bookmark = UTType ( exportedAs : "com.genjiapp.TableSample.bookmark" )
}
struct Bookmark : Identifiable , Codable , Transferable {
static var transferRepresentation : some TransferRepresentation {
CodableRepresentation ( contentType : . bookmark )
}
var id = UUID ()
var title : String
var url : URL
}
struct ContentView : View {
@State var sampleData : [ Bookmark ] = [
Bookmark ( title : "Genji App" , url : URL ( string : "https://genjiapp.com/" ) ! ),
Bookmark ( title : "Apple" , url : URL ( string : "https://www.apple.com/" ) ! ),
Bookmark ( title : "Google" , url : URL ( string : "https://www.google.com/" ) ! )
]
@State private var selectedID : Bookmark . ID ?
var body : some View {
Table ( of : Bookmark . self , selection : $ selectedID ) {
TableColumn ( "Title" ) { bookmark in
Text ( bookmark . title )
}
TableColumn ( "URL" ) { bookmark in
Text ( bookmark . url . absoluteString )
}
} rows : {
ForEach ( sampleData ) { bookmark in
TableRow ( bookmark )
. draggable ( bookmark )
}
. dropDestination ( for : Bookmark . self ) { insertionIndex , insertionBookmarks in
if let bookmark = insertionBookmarks . first ,
let originalIndex = sampleData . firstIndex ( where : { $0 . id == bookmark . id }) {
sampleData . insert ( bookmark , at : insertionIndex )
let removeIndex = ( insertionIndex > originalIndex ) ? originalIndex : originalIndex + 1
sampleData . remove ( at : removeIndex )
}
}
}
. tableStyle ( . bordered )
. padding ()
}
}
draggableはTableRowに、dropDestinationはTableRowを囲むForEachに対して指定する。
また、用意したカスタムUTTypeは、その識別子をInfo.plist内で宣言しておかなければならない。
2023年06月19日, 編集履歴
Mailto Interceptor ver. 1.4をリリースしました。
mailto:リンクのクリック等によるメールアプリケーションの意図しない即時起動を抑制できるmacOSアプリケーションです。デフォルトのメールアプリケーションとしてMailto Interceptorを指定することで、mailto:リンクのクリック等があったときに、
何もしない
メールアドレスをコピー
URLを開く(ウェブメールの作成画面等)
動作を選択できるポップアップメニューを表示させる
といった動作をさせることができます。
ver. 1.4では、
「動作を通知」機能をシステム標準の通知センターを使うように変更
コードの近代化改修
を行いました。
今となってはmailto:リンクを見ることも少なくなってはきましたが、どうぞよろしくお願いします。
2023年03月18日, 編集履歴
いつの頃からかは解らないが、少なくともmacOS 13.2.1のSwiftUIでは.opacity()と.onHover()の順番が重要になっている。
環境:
macOS 13.2.1
Xcode 14.2 (14C18)
以下のようなSwiftUIビューがあったとする。
struct ContentView : View {
@State private var isOn = false
var body : some View {
Toggle ( "Toggle" , isOn : $ isOn )
. toggleStyle ( . switch )
. onHover { isHovering in
print ( isHovering )
}
. padding ()
}
}
Toggleスウィッチの領域にマウスポインタを持っていく(ホバーする)と、.onHover()が反応する。ここで.opacity()を付けてToggleに透明度を設定したいとする。
struct ContentView : View {
@State private var isOn = false
var body : some View {
Toggle ( "Toggle" , isOn : $ isOn )
. toggleStyle ( . switch )
. onHover { isHovering in
print ( isHovering )
}
. opacity ( 0.0 )
. padding ()
}
}
.onHover()の後ろに.opacity()を付与した場合、macOS 11では完全に透明にしても.onHover()は反応していたが、macOS 13では反応しなくなっていた。完全に透明にしたのがいけなかったのかと思い、.opacity(0.5)とかしてみると反応してくれる。なるほど。では、.opacity(0.3)なら? なぜか「Toggle」と書かれたラベル部分にマウスを乗せても反応せず、スウィッチ部分に乗せた場合にだけ反応する。.onHover()が反応する領域が変化するという変な挙動を示す。
struct ContentView : View {
@State private var isOn = false
var body : some View {
Toggle ( "Toggle" , isOn : $ isOn )
. toggleStyle ( . switch )
. opacity ( 0.0 )
. onHover { isHovering in
print ( isHovering )
}
. padding ()
}
}
のように.opacity()を先に記述すると、完全に透明にした場合でも.onHover()が反応するし、ラベル部分であってもちゃんと反応領域に含まれる。
というわけで、(少なくともmacOS 13.2.1では).opacity()が先、.onHover()が後という順番でないといけない。