feed

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

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

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

2021年08月23日, 編集履歴

SwiftUIでDocument-Based Appな画像閲覧アプリを作る その1 プロジェクト作成から画像の表示まで

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

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

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

ビルド環境は、

である。

Document Appテンプレートでプロジェクト作成

SwiftUIでDocument-Based Appなプロジェクトを作成するには「Document App」テンプレートを使用する。今回はmacOS/iOS両対応で作りたいので、「Multiplatform」の「Document App」テンプレートを選択してプロジェクトを作成する。

Info.plistの設定

Info.plistファイルで、アプリが読み書きできるファイル形式を以下の項目で指定する。

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 }を実装する。

NSImageUIImageを一律に扱う

今回のアプリはmacOSとiOSの両対応にしたい。それぞれの環境で画像を表すオブジェクトであるNSImageUIImageを一律に扱うため、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で実装する。この場合、

が必要である。

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
  }

}

読み込み処理は、単純に画像を読み込んでIVImageNSImageUIImage)型の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())
  }
}

今回のドキュメントモデルで採用したReferenceFileDocumentObservableObjectに適合しているので、プロパティとして保持するときは@ObservedObjectで受ける。大きな画像を開いたときにスクロールできるように、ImageScrollViewに内包させた。

ここまでで、とりあえずPNG/JPEG画像を閲覧するアプリが動く。

Document-Based Appなので、複数のウィンドウで別々の画像を開いたり、複数開いたウィンドウをタブでひとつのウィンドウにまとめたりできる。

次回はピンチイン/アウトジェスチャによる画像の拡大縮小の実装を行う。

2021年07月22日, 編集履歴

Murasaki ver. 2.4をリリースしました

Murasaki ver. 2.4をリリースしました。macOS用のEPUBリーダアプリです。

前回からの主な変更点は、

です。

今回から対応OSはmac OS Big Sur以降になります。また、Spotlight/Quick Lookプラグインは廃止になります。

2021年07月19日, 編集履歴

Silver Baton ver. 1.4をリリースしました

Silver Baton ver. 1.4をリリースしました。手元のトラックパッド/マウス操作でMusic.appを操作できるパネルを表示するmacOS用アプリです。

前回からの主な変更点は、

です。

環境設定ウィンドウに、アプリが必要とする権限の確認、およびその取得を誘導するビューを追加しました。

また、アプリ起動時に環境設定ウィンドウを表示するようにし、その動作を抑制できる設定を追加しました。これは、権限の取得やパネル表示方法の選択など、Silver Batonの初期導入をスムーズに行えるようにすることを狙っています。

環境設定ウィンドウの改修にあたり一部SwiftUIを採用しました。環境設定ウィンドウにある3つのビューがそれぞれSwiftUIで作られています。ガワとなるウィンドウおよびNSTabViewControllerは既存のStoryboardを使い、各ビューだけをSwiftUIに置き換えた形となります。

今後はmacOSアプリもSwiftUIが主流になっていくと思われ、その練習がてらとして使ってみました。