feed

2021年12月03日, 編集履歴

Murasaki ver. 2.4.1をリリース

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 macOSアプリでファイルを開く

SwiftUIの非Document AppなmacOSアプリにおいて、ファイルを開く処理を実装する。今回は以下の3つの手法を試した。

成果物をGitHubに公開している。

ビルド環境は、

である。

NSOpenPanel

まずは、これまで通りNSOpenPanelを使う方法から。以下のようなビューがあったとする。

ImageButtonが並んでおり、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()
    }
  }
}

Buttonactionコールバック内でNSOpenPanelを生成し、runModal()してやればよい。その返り値が.OKの場合はファイル選択が成功し、NSOpenPanelurlプロパティに選択されたファイルのURLが入っている。

対応形式にUTIが求められるときはUTType型を使うとよい。UTTypeは新しい型で、UTIを使うAPIによってはUTType型を求められたり、String型で求められたりが混在している。String型を求められているAPIならUTTypeidentifierを使う。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
}

これで、

等々からのドラッグ&ドロップで画像を読み込み、表示することができる。

ただし、Safariで画像そのもののURLを開いて、アドレス欄のURLをドラッグ&ドロップした場合は読み込めない。この場合の読み込み方法は謎である。

また、loadItem()の引数でUTType.image.identifierを指定しているのに、取得できるのがURL型になるのも変な感じがする。直感的にはData型になりそうなものだが……(NSImage(data:)で画像データを作れるイメージ)。

最後に

今回は説明のためにそれぞれの読み込み方法を別々のビューに実装したが、実際に使用する場合は前二者の方法とonDrop()の方法を組み合わせたり、メニューからの読み込み処理の呼び出しを実装したりするとよい。

2021年08月29日, 編集履歴

SwiftUIでマウスホバー時にオーバレイ表示される操作UIを実装する

ウィンドウ下部にマウスを持っていくと、メインの領域の上に重なるように表示される操作UIを実装する。こんな感じ。

動画プレイヤアプリの再生ボタン等がこんな感じで実装されていることがよくある。本項ではこれを「オーバレイ操作UI」と呼ぶことにする。

成果物をGitHubに公開する。

ビルド環境は、

である。

操作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をウィンドウ下部に配置したいので、VStackSpacerを組み合わせた。また、UI部品の背景を描画するため、backgroundCapsuleを使用した。メインの領域と境界をわかりやすくするため、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全体を包むVStackopacityの条件として用いる。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が出ている状態にする。それと同時に、ビュー全体を包むZStackonAppearを付けて、その中で数秒後にisHoverfalseに切り替える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 ツールバーの実装

SwiftUIでDocument-Based Appな画像閲覧アプリ習作の覚書その3。PNG/JPEG画像を開いて閲覧、スクロール、ピンチジェスチャで拡大縮小、ツールバーにボタン配置等を実装した。

このシリーズの他のブログは、

作成したプロジェクトはGitHubで公開している。

ビルド環境は、

である。

前回、表示した画像をジェスチャで拡大縮小できるようにした。今回はツールバーを実装する。

ツールバーの実装

ビューに対してtoolbar(content:)を付与すると、ツールバーをつけることができる。引数contentにはToolbarContentに適合したオブジェクト、具体的にはToolbarItemToobarItemGroupを与える。

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 ジェスチャによる拡大縮小

SwiftUIでDocument-Based Appな画像閲覧アプリ習作の覚書その2。PNG/JPEG画像を開いて閲覧、スクロール、ピンチジェスチャで拡大縮小、ツールバーにボタン配置等を実装した。

このシリーズの他のブログは、

作成したプロジェクトはGitHubで公開している。

ビルド環境は、

である。

前回までで、画像を開いて表示できるところまでを作成した。今回は開いた画像の拡大縮小表示の実装を行う。

MagnificationGestureでピンチジェスチャの実装

ビューに対して.gesture(_:including:)を付けるとジェスチャ操作を実装できる。第1引数はGestureプロトコルに適合したオブジェクトを渡す。標準で、

が用意されているので、いずれかを使う。今回はピンチジェスチャによる画像の拡大縮小表示がしたいので、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:)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が拡大縮小表示されるようになるが、問題点がふたつある。

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:)で加えた制限とは関係がないので、改めて値の制限を行う。

ここまでで、ジェスチャ操作の実装は完了である。

次回はツールバーを実装する。