feed
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:)
で加えた制限とは関係がないので、改めて値の制限を行う。
ここまでで、ジェスチャ操作の実装は完了である。
次回はツールバーを実装する。
2021年08月23日, 編集履歴
SwiftUIでDocument-Based Appな画像閲覧アプリを習作した。PNG/JPEG画像を開いて閲覧、スクロール、ピンチジェスチャで拡大縮小、ツールバーにボタン配置等を実装した。その覚書その1。
このシリーズの他のブログは、
プロジェクトはGitHubで公開している。
ビルド環境は、
macOS 11.5.2
Xcode 12.5.1
である。
Document Appテンプレートでプロジェクト作成
SwiftUIでDocument-Based Appなプロジェクトを作成するには「Document App」テンプレートを使用する。今回はmacOS/iOS両対応で作りたいので、「Multiplatform」の「Document App」テンプレートを選択してプロジェクトを作成する。
Info.plistの設定
Info.plistファイルで、アプリが読み書きできるファイル形式を以下の項目で指定する。
Document Types(CFBundleDocumentTypes
)
Imported Type Identifiers(UTImportedTypeDeclarations
)
Exported Type Identifiers(UTExportedTypeDeclarations
)
Document Typesは必須、既存のファイル形式を扱う場合は「Imported 〜」を、アプリ独自の形式を定義する場合は「Exported 〜」を使用する。
今回は既存のPNG/JPEG画像を扱いたいので、「Exported 〜」は不使用。
Document Typesでは、PNG/JPEGそれぞれに形式の名称やUTIを指定する。また、アプリは画像の閲覧専用とするので、「Role」は「Viewer」を、「Handler Rank」は「Alternate」を選択した。
Imported Type Identifiersでは、それぞれの形式のUTIや拡張子、MIME Type等を指定する。
macOS/iOSのInfo.plistは独立しているので、両対応させる場合は両方で設定を行う必要がある。
FileDocument
/ReferenceFileDocument
で対応形式を宣言
SwiftUI Document-Based Appでは、対応するファイル形式のモデルをFileDocument
あるいはReferenceFileDocument
に適合したオブジェクトで表現する。FileDocument
あるいはReferenceFileDocument
が持つstatic var readableContentTypes: [UTType] { get }
プロパティで、対応する形式のUTIを[UTType]
で返すようにする。
static var readableContentTypes : [ UTType ] {
[
UTType ( importedAs : "public.png" ),
UTType ( importedAs : "public.jpeg" )
]
}
今回はPNG/JPEG画像を同様に扱いたいので、ひとつのドキュメントモデルで一緒に宣言した。形式によって読み書きの処理等を変えたい場合は、複数のドキュメントモデルを作成する。
ファイルの書き込みにも対応する場合は、static var writableContentTypes: [UTType] { get }
を実装する。
NSImage
、UIImage
を一律に扱う
今回のアプリはmacOSとiOSの両対応にしたい。それぞれの環境で画像を表すオブジェクトであるNSImage
、UIImage
を一律に扱うため、typealias
を使ってIVImage
なる型を定義する。
#if os(macOS)
typealias IVImage = NSImage
#elseif os(iOS)
typealias IVImage = UIImage
#endif
また、後の工程で画像を表示するビューとしてSwiftUIのImage
を使うが、IVImage
を扱えるようにextension
で拡張する。
extension Image {
init ( ivImage : IVImage ) {
#if os(macOS)
self . init ( nsImage : ivImage )
#elseif os(iOS)
self . init ( uiImage : ivImage )
#endif
}
}
ドキュメントモデルで読み書き処理を実装
FileDocument
/ReferenceFileDocument
なドキュメントのモデルで画像の読み書きの処理を実装する。次回の工程でモデルのプロパティを@Published
にしたかったので、今回のアプリではReferenceFileDocument
で実装する。この場合、
typealias Snapshot
の定義
init(configuration:)
の実装
func fileWrapper(snapshot:configuration:)
の実装
func snapshot(contentType:)
の実装
が必要である。
class ImageDocument : ReferenceFileDocument {
typealias Snapshot = IVImage
static var readableContentTypes : [ UTType ] {
[
UTType ( importedAs : "public.png" ),
UTType ( importedAs : "public.jpeg" )
]
}
var image : IVImage
init ( image : IVImage = IVImage ()) {
self . image = image
}
required init ( configuration : ReadConfiguration ) throws {
guard let data = configuration . file . regularFileContents ,
let image = IVImage ( data : data )
else {
throw CocoaError ( . fileReadCorruptFile )
}
self . image = image
}
func fileWrapper ( snapshot : IVImage , configuration : WriteConfiguration ) throws -> FileWrapper {
throw CocoaError ( . fileWriteUnknown )
}
func snapshot ( contentType : UTType ) throws -> IVImage {
return self . image
}
}
読み込み処理は、単純に画像を読み込んでIVImage
(NSImage
、UIImage
)型のimage
プロパティに格納する。func fileWrapper(snapshot:configuration:)
はファイルの書き込みに用いられるが、今回のアプリは閲覧専用なのでエラーを吐かせておく。
init(image:)
は後の工程でビューのプレビューを表示させるときに空のドキュメントを供給するときに必要なので実装しておいた。
DocumentGroup
の指定
App
プロトコルに適合した構造体の中、DocumentGroup
シーンでドキュメントモデルの指定を行う。
struct ImageViewerSwiftUIApp : App {
var body : some Scene {
DocumentGroup ( viewing : ImageDocument . self ) { file in
ContentView ( document : file . document )
}
}
}
今回は閲覧専用なので、init(viewing:viewer:)
を使った。第1引数にはドキュメントモデルの型を指定する。複数のドキュメントモデルを用いる場合は、DocumentGroup
を複数宣言する。
ビューの作成
DocumentGroup
内で指定したビューでドキュメントの内容を表示する(前項のContentView
)。
struct ContentView : View {
@ObservedObject var document : ImageDocument
var body : some View {
ScrollView ([ . horizontal , . vertical ]) {
Image ( ivImage : document . image )
}
}
}
struct ContentView_Previews : PreviewProvider {
static var previews : some View {
ContentView ( document : ImageDocument ())
}
}
今回のドキュメントモデルで採用したReferenceFileDocument
はObservableObject
に適合しているので、プロパティとして保持するときは@ObservedObject
で受ける。大きな画像を開いたときにスクロールできるように、Image
をScrollView
に内包させた。
ここまでで、とりあえずPNG/JPEG画像を閲覧するアプリが動く。
Document-Based Appなので、複数のウィンドウで別々の画像を開いたり、複数開いたウィンドウをタブでひとつのウィンドウにまとめたりできる。
次回 はピンチイン/アウトジェスチャによる画像の拡大縮小の実装を行う。
2021年07月22日, 編集履歴
Murasaki ver. 2.4をリリースしました。macOS用のEPUBリーダアプリです。
前回からの主な変更点は、
サイドバー開閉に関する問題を修正
ツールバーボタンの位置に関する問題を修正
環境設定ウィンドウを修正
Spotlight/Quick Lookプラグインを廃止
です。
今回から対応OSはmac OS Big Sur以降 になります。また、Spotlight/Quick Lookプラグインは廃止 になります。
2021年07月19日, 編集履歴
Silver Baton ver. 1.4をリリースしました。手元のトラックパッド/マウス操作でMusic.appを操作できるパネルを表示するmacOS用アプリです。
前回からの主な変更点は、
です。
環境設定ウィンドウに、アプリが必要とする権限の確認、およびその取得を誘導するビューを追加しました。
また、アプリ起動時に環境設定ウィンドウを表示するようにし、その動作を抑制できる設定を追加しました。これは、権限の取得やパネル表示方法の選択など、Silver Batonの初期導入をスムーズに行えるようにすることを狙っています。
環境設定ウィンドウの改修にあたり一部SwiftUIを採用しました。環境設定ウィンドウにある3つのビューがそれぞれSwiftUIで作られています。ガワとなるウィンドウおよびNSTabViewControllerは既存のStoryboardを使い、各ビューだけをSwiftUIに置き換えた形となります。
今後はmacOSアプリもSwiftUIが主流になっていくと思われ、その練習がてらとして使ってみました。