feed
2021年12月03日, 編集履歴
Murasaki ver. 2.4.1をリリースしました。macOS用のEPUBリーダアプリです。
前回からの主な変更点は、
macOS 12以降のみをサポート
Spotlight/Quick Lookプラグインの再同梱
です。
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に公開している。
ビルド環境は、
macOS 11.5.2
Xcode 12.5.1
である。
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
が入るので、オーバレイ表示するビューの表示・非表示の条件に用いた。
2023年4月3日追記
(たぶん)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
}
これで、
Finderに表示している画像ファイル
プレビュー.appのタイトルバーアイコン
Safariに表示された画像
ミュージック.appの「情報を見る」の「アートワーク」の画像
等々からのドラッグ&ドロップで画像を読み込み、表示することができる。
ただし、Safariで画像そのもののURLを開いて、アドレス欄のURLをドラッグ&ドロップした場合は読み込めない。この場合の読み込み方法は謎である。
また、loadItem()
の引数でUTType.image.identifier
を指定しているのに、取得できるのがURL
型になるのも変な感じがする。直感的にはData
型になりそうなものだが……(NSImage(data:)
で画像データを作れるイメージ)。
最後に
今回は説明のためにそれぞれの読み込み方法を別々のビューに実装したが、実際に使用する場合は前二者の方法とonDrop()
の方法を組み合わせたり、メニューからの読み込み処理の呼び出しを実装したりするとよい。
2021年08月29日, 編集履歴
ウィンドウ下部にマウスを持っていくと、メインの領域の上に重なるように表示される操作UIを実装する。こんな感じ。
動画プレイヤアプリの再生ボタン等がこんな感じで実装されていることがよくある。本項ではこれを「オーバレイ操作UI」と呼ぶことにする。
成果物をGitHubに公開する。
ビルド環境は、
macOS 11.5.2
Xcode 12.5.1
である。
操作UIの重ね合わせ表示
一例として以下のようなビューがあるとする。
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が表示されるようになるが、スライダ操作中に領域外へ出てしまうと非表示になってしまう問題がある。
2023年3月18日追記
少なくとも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
}
}
}
}
完成!
2021年08月26日, 編集履歴
SwiftUIでDocument-Based Appな画像閲覧アプリ習作の覚書その3。PNG/JPEG画像を開いて閲覧、スクロール、ピンチジェスチャで拡大縮小、ツールバーにボタン配置等を実装した。
このシリーズの他のブログは、
作成したプロジェクトはGitHubで公開している。
ビルド環境は、
macOS 11.5.2
Xcode 12.5.1
である。
前回 、表示した画像をジェスチャで拡大縮小できるようにした。今回はツールバーを実装する。
ツールバーの実装
ビューに対してtoolbar(content:)
を付与すると、ツールバーをつけることができる。引数content
にはToolbarContent
に適合したオブジェクト、具体的にはToolbarItem
かToobarItemGroup
を与える。
var body : some View {
ScrollView ([ . horizontal , . vertical ]) {
...
}
. toolbar {
ToolbarItem {
Button ( "Button 1" ) { ... }
}
ToolbarItemGroup {
Button ( "Button 2" ) { ... }
Button ( "Button 3" ) { ... }
}
}
}
のような感じ。toolbar(content:)
の中に適当に要素を列挙していけばよいが、長くなると本来のビューの構造が解りづらくなるので、今回はサブビュー化する方針を採った。
struct ContentView : View {
...
var body : some View {
ScrollView ([ . horizontal , . vertical ]) {
...
}
. toolbar {
MagnifyToolbarButtons ( document : self . document )
}
}
}
struct MagnifyToolbarButtons : ToolbarContent {
let document : ImageDocument
var body : some ToolbarContent {
#if os(iOS)
let placement = ToolbarItemPlacement . bottomBar
#else
let placement = ToolbarItemPlacement . automatic
#endif
ToolbarItemGroup ( placement : placement ) {
Button ( action : {
self . document . scaleViewSize ( 0.5 , animate : true )
}) {
Image ( systemName : "minus.magnifyingglass" )
}
Button ( action : {
self . document . resetViewSize ( animate : true )
}) {
Image ( systemName : "equal.circle" )
}
Button ( action : {
self . document . scaleViewSize ( 2.0 , animate : true )
}) {
Image ( systemName : "plus.magnifyingglass" )
}
}
}
}
このとき、サブビューの構造体とそのbody
プロパティはToolbarContent
に適合させるようにする。
また、iPhoneの場合、画面上部のツールバーに複数のボタンを配置しても最初のひとつしか表示されないため、画面下部のツールバーに表示させるようOSによる場合分けを行なった。
2021年08月24日, 編集履歴
SwiftUIでDocument-Based Appな画像閲覧アプリ習作の覚書その2。PNG/JPEG画像を開いて閲覧、スクロール、ピンチジェスチャで拡大縮小、ツールバーにボタン配置等を実装した。
このシリーズの他のブログは、
作成したプロジェクトはGitHubで公開している。
ビルド環境は、
macOS 11.5.2
Xcode 12.5.1
である。
前回 までで、画像を開いて表示できるところまでを作成した。今回は開いた画像の拡大縮小表示の実装を行う。
MagnificationGesture
でピンチジェスチャの実装
ビューに対して.gesture(_:including:)
を付けるとジェスチャ操作を実装できる。第1引数はGesture
プロトコルに適合したオブジェクトを渡す。標準で、
TapGesture
LongPressGesture
DragGesture
MagnificationGesture
RotationGesture
が用意されているので、いずれかを使う。今回はピンチジェスチャによる画像の拡大縮小表示がしたいので、MagnificationGesture
を使う。
struct ContentView : View {
...
@GestureState private var scale : CGFloat = 1.0
var magnificationGesture : some Gesture {
MagnificationGesture ()
// gestureState に値を代入すると、それが @GestureState のプロパティに入る。
// @GestureState のプロパティはジェスチャ終了時に自動的に初期値にリセットされる。
// 急激な拡大・縮小を防ぐため、値の範囲に制限を加える。
. updating ( self . $ scale ) { currentValue , gestureState , _ in
if currentValue < 0.1 {
gestureState = 0.1
}
else if currentValue > 5 {
gestureState = 5
}
else {
gestureState = currentValue
}
}
// ジェスチャ完了時の最終的な値が finalValue に入っている。
// これを使って実際に表示サイズを変更する。
// .updating() で @GestureStateに加えた制限は finalValue には
// 適用されないので、改めて範囲制限を加える。
. onEnded { finalValue in
var scale = finalValue
if scale < 0.1 {
scale = 0.1
}
else if scale > 5 {
scale = 5
}
self . document . scaleViewSize ( scale )
}
}
var body : some View {
ScrollView ([ . horizontal , . vertical ]) {
Image ( ivImage : document . image )
. resizable ()
. aspectRatio ( contentMode : . fit )
// ジェスチャ中の見掛け上の表示サイズ変更をする。
// @GestureState なプロパティはジェスチャ終了時には初期値にリセットされる。
. scaleEffect ( self . scale )
// ジェスチャ完了後の実際の表示サイズは .frame(width:, height:) を使う。
// .scaleEffect() では ScrollView から見た表示サイズが変更されないので、
// スクロールが狂う。
. frame ( width : self . document . viewSize . width ,
height : self . document . viewSize . height )
. gesture ( magnificationGesture )
}
}
}
ここで、Gesture
プロトコルにはジェスチャ操作中の値変化ごとに発火するメソッドがふたつと、ジェスチャ操作終了後に発火するメソッドがひとつ存在する。
updating(_:body:)
@GestureState
なプロパティと組み合わせて、値変化ごとの一時的なビューの状態変化を実装するときに使用
onChanged(_:)
値変化ごとのビューの永続的な状態変化を実装するときに使用
onEnded(_:)
ジェスチャ操作完了後の最後の値が渡ってくる。
今回はジェスチャ操作中の一時的な見掛け上の拡大縮小表現を、updating(_:body:)
とImage
ビューにつけたscaleEffect(_:anchor:)
で実装した。実際の表示サイズ変更はジェスチャ操作完了後にonEnded(_:)
で実装した。
updating(_:body:)
の実装
updating(_:body:)
の第1引数に@GestureState
なプロパティを渡すと、ジェスチャ操作中の値をそのプロパティから見ることができる。ジェスチャの値は種類ごとに異なり、MagnificationGesture
の場合はtypealias Value = CGFloat
として実装されており、拡大縮小率として使用できる。
updating(_:body:)
の第2引数は@escaping (Self.Value, inout State, inout Transaction) -> Void)
なコールバックになっており、その第1引数は現在のジェスチャの値、第2引数はupdating(:body:)
の第1引数で与えた@GestureState
なプロパティのエイリアス的な変数でinout
指定になっている。この第2引数に値を代入することで、@GestureState
なプロパティに操作中のジェスチャの値が入る。
updating(:body:)
に関係するところを抜き出した実装が以下である。
struct ContentView : View {
@ObservedObject var document : ImageDocument
@GestureState private var scale : CGFloat = 1.0
var magnificationGesture : some Gesture {
MagnificationGesture ()
. updating ( self . $ scale ) { currentValue , gestureState , _ in
if currentValue < 0.1 {
gestureState = 0.1
}
else if currentValue > 5 {
gestureState = 5
}
else {
gestureState = currentValue
}
}
}
var body : some View {
ScrollView ([ . horizontal , . vertical ]) {
Image ( ivImage : document . image )
. scaleEffect ( self . scale )
. gesture ( self . magnificationGesture )
}
}
}
急激な拡大縮小を防ぐため、@GestureState
なプロパティ(のエイリアス的存在であるgestureState
)には値の範囲の制限を加えている。
これでジェスチャ操作中にImage
が拡大縮小表示されるようになるが、問題点がふたつある。
@GestureState
なプロパティはジェスチャ操作が終了すると初期値にリセットされる。したがって、上の実装だけでは、ジェスチャ操作を終えると元の大きさに戻る(.scaleEffect(1.0)
と同義となる)。
scaleEffect(_:anchor:)
は見掛け上の拡大縮小をするだけで、ScrollView
から見たImage
のサイズが変わるわけではない。したがって、最終的な表示サイズ変更をscaleEffect(:anchor:)
だけに任せるとスクロールが狂う。
viewSize
プロパティの実装とframe(width:height:alignment:)
の付与
前項の問題点解消のため、Image
の永続的な表示サイズの変更にはframe(width:height:alignment:)
を用いる。このとき、引数に与える幅と高さを保持するviewSize
プロパティをドキュメントモデルに実装する。
class ImageDocument : ReferenceFileDocument {
...
var image : IVImage
@Published var viewSize : CGSize
init ( image : IVImage = IVImage ()) {
self . image = image
self . viewSize = image . size
}
required init ( configuration : ReadConfiguration ) throws {
guard let data = configuration . file . regularFileContents ,
let image = IVImage ( data : data )
else {
throw CocoaError ( . fileReadCorruptFile )
}
self . image = image
self . viewSize = image . size
}
...
// MARK: -
func scaleViewSize ( _ scale : CGFloat ) {
self . scaleViewSize ( scale , animate : false )
}
func scaleViewSize ( _ scale : CGFloat , animate : Bool ) {
var newViewSize = CGSize ( width : self . viewSize . width * scale , height : self . viewSize . height * scale )
if newViewSize . width < self . image . size . width * 0.2 {
newViewSize . width = self . image . size . width * 0.2
newViewSize . height = self . image . size . height * 0.2
}
else if newViewSize . width > self . image . size . width * 5 {
newViewSize . width = self . image . size . width * 5
newViewSize . height = self . image . size . height * 5
}
if animate {
withAnimation {
self . viewSize = newViewSize
}
}
else {
self . viewSize = newViewSize
}
}
func resetViewSize () {
self . resetViewSize ( animate : false )
}
func resetViewSize ( animate : Bool ) {
if animate {
withAnimation {
self . viewSize = self . image . size
}
}
else {
self . viewSize = self . image . size
}
}
}
viewSize
プロパティの変化をビューに通知して再描画させるため、@Published
を付けた。image
プロパティのsize
で初期化する。また、拡大縮小率を与えてviewSize
プロパティを変化させるメソッドも実装した。拡大縮小されすぎないような制限も加えている。
このviewSize
プロパティを用いてImage
の表示サイズを変更する。
Image ( ivImage : document . image )
. resizable ()
. aspectRatio ( contentMode : . fit )
. scaleEffect ( self . scale )
. frame ( width : self . document . viewSize . width ,
height : self . document . viewSize . height )
. gesture ( magnificationGesture )
表示サイズの変更にframe(width:height:alignment:)
を用いた。またサイズ変更可能にするため、resizable(capInsets:resizingMode:)
を併せて付与する。今回のサイズ変更はかならず縦横同率で変更するので必要ないがaspectRatio(_:contentMode:)
も一応つけておく。
この段階では、まだ実際にviewSize
を変化させる処理の呼び出しがないので、永続的なサイズ変更はされない。
onEnded(_:)
の実装
実際の表示サイズ変更処理の呼び出しをonEnded(_:)
で実装する。
var magnificationGesture : some Gesture {
MagnificationGesture ()
. updating ( self . $ scale ) { currentValue , gestureState , _ in
...
}
. onEnded { finalValue in
var scale = finalValue
if scale < 0.1 {
scale = 0.1
}
else if scale > 5 {
scale = 5
}
self . document . scaleViewSize ( scale )
}
}
onEnded(_:)
の第1引数は@escaping (Self.Value) -> Void
なコールバックでジェスチャ操作完了時に呼ばれる。その第1引数にはジェスチャの最終的な値が入っている。この最終値はupdating(_:body:)
で加えた制限とは関係がないので、改めて値の制限を行う。
ここまでで、ジェスチャ操作の実装は完了である。
次回はツールバーを実装する。