Tips Of SwiftUI about PopSheet, IfLet, ShareSheet

# はじめに

こんにちは!

先日、「メモなう」というアプリをリリースしました。

「メモなう」はただ単なるメモアプリです。

メモなう

メモなう

fumiya yamanaka 無料 posted with アプリーチ

このアプリの制作の裏側については「メモなうをリリースしました」をご覧ください。

メモなうは SwiftUI と Firebase を利用して開発しています。

今回の記事では、アプリを作っているうちにハマった SwiftUI における Tips をいくつか紹介していきます。

macOS へ対応していたり、SwiftUIFluxを利用したりと、書きたいことは山ほどありますが、またの機会に。。。 💪

# 目次

  • NavigationLink: 右矢印を消す!
  • PopSheet: ActionSheet と Popover を使い分ける!
  • IfLet: nil じゃなければ表示したい View!
  • ShareSheet(UIActivityViewController): SwiftUI でシェアをしたい!

SwiftUI ではほぼ必須のNavigationLinkを利用すると、デザインの側面から右矢印が邪魔なことが多々あります。

なので表示しないようにする方法を探していました。

いくつか方法はあるようですが、下記のやり方が、個人的にはしっくりきていて採用しました。

Before

NavigationLink(destination: DestinationHogeView()) {
  OriginalHogeView()
}
1
2
3

After

ZStack {
  OriginalHogeView()
  NavigationLink(destination: DestinationHogeView()) {
    EmptyView()
  }
}
1
2
3
4
5
6

矢印はNavigationLink内の View に反映されるようなのでそこにはEmptyViewを渡しておいて、実際に表示したい View はZStackで重ねるように表示してあげます。

参考: [Solution] SwiftUI ActionSheet crash on iPad

# PopSheet

ユーザに選択肢の中から選んでもらいたい時には、ActionSheet を利用すると便利です。

下からニュッとでてくるあいつです。

しかし、ActionSheetは iPad では使えず、クラッシュしてしまいます。

なので代用としてPopoverという View から吹き出しをつけたような UI の物を使うのがよしとされており、出しわけを行う必要があります。

端末の判定にはUIDevice.current.userInterfaceIdiom == .padを行うのが良いです。

View の extension メソッドをつくり、勝手に出しわけしてくれるようにします。

popover だった場合にどうやって表示するかを指定しておけばよしなにやってくれるようになります。

PopoverAttachmentAnchorについて少し手間取りましたが、いくつか試してみればわかるかと思います。

HogeView()
  .popSheet(
    isPresented: self.$showPopSheet,
    arrowEdge: .leading,
    attachmentedAnchor: .point(.trailing),
    content: { self.popSheet }
  )
1
2
3
4
5
6
7

実際のコードも置いておきます。

PopSheet.swift
extension View {
  func popSheet(
    isPresented: Binding<Bool>,
    arrowEdge: Edge,
    attachmentedAnchor: PopoverAttachmentAnchor,
    content: @escaping () -> PopSheet
  ) -> some View {
    Group {
      if UIDevice.current.userInterfaceIdiom == .pad {
        popover(
          isPresented: isPresented,
          attachmentAnchor: attachmentedAnchor,
          arrowEdge: arrowEdge,
          content: { content().popover(isPresented: isPresented) }
        )
      } else {
        actionSheet(isPresented: isPresented, content: { content().actionSheet() })
      }
    }
  }
}

struct PopSheet {
  let title: Text
  let message: Text?
  let buttons: [PopSheet.Button]

  public init(title: Text, message: Text? = nil, buttons: [PopSheet.Button] = [.cancel()]) {
    self.title = title
    self.message = message
    self.buttons = buttons
  }

  func actionSheet() -> ActionSheet {
    ActionSheet(title: title, message: message, buttons: buttons.map({ popButton in
      switch popButton.kind {
      case .default: return .default(popButton.label, action: popButton.action)
      case .cancel: return .cancel(popButton.label, action: popButton.action)
      case .destructive: return .destructive(popButton.label, action: popButton.action)
      }
    }))
  }

  func popover(isPresented: Binding<Bool>) -> some View {
    VStack {
      title.padding(.top)
      Divider()
      List {
        ForEach(Array(self.buttons.enumerated()), id: \.offset) { (offset, button) in
          VStack {
            SwiftUI.Button(action: {
              isPresented.wrappedValue = false
              DispatchQueue.main.async {
                button.action?()
              }
            }, label: {
              button.label.font(.subheadline)
            })
          }
        }
      }
    }
  }

  public struct Button {
    let kind: Kind
    let label: Text
    let action: (() -> Void)?
    enum Kind { case `default`, cancel, destructive }

    /// Creates a `Button` with the default style.
    public static func `default`(_ label: Text, action: (() -> Void)? = {}) -> Self {
      Self(kind: .default, label: label, action: action)
    }

    /// Creates a `Button` that indicates cancellation of some operation.
    public static func cancel(_ label: Text = Text(R.string.localizable.cancel()), action: (() -> Void)? = {}) -> Self {
      Self(kind: .cancel, label: label.bold(), action: action)
    }

    /// Creates an `Alert.Button` with a style indicating destruction of some data.
    public static func destructive(_ label: Text, action: (() -> Void)? = {}) -> Self {
      Self(kind: .destructive, label: label, action: action)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86

参考: How can create a share sheet/view with SwiftUI for iOS 13 ?

# IfLet

SwiftUI を試したことのある人ならばわかるかと思いますが、現時点では ViewBuilder 内でif letが使えません。

ぶっちゃけ不便だなと感じました。同様に switch 文も書けません。

強制 Unwrap するか、 if elseを使うかなど対応が必要です。

そんな時に便利なのがIfLetView です!!

こちらは、Optional なオブジェクトを渡して、実態があれば View を表示すると言うものになっています。

IfLet(self.email?) { Text("Email: \($0)") }
1

上記の場合だと、email が nil じゃなければ、その email を含めた Text を表示するようになっています。

IfLet.swift
/// https://stackoverflow.com/a/60203901
struct IfLet<Value, Content, NilContent>: View where Content: View, NilContent: View {

  let value: Value?
  let contentBuilder: (Value) -> Content
  let nilContentBuilder: () -> NilContent

  init(
    _ optionalValue: Value?,
    @ViewBuilder whenPresent contentBuilder: @escaping (Value) -> Content,
                 @ViewBuilder whenNil nilContentBuilder: @escaping () -> NilContent
  ) {
    self.value = optionalValue
    self.contentBuilder = contentBuilder
    self.nilContentBuilder = nilContentBuilder
  }

  var body: some View {
    Group {
      if value != nil {
        contentBuilder(value!)
      } else {
        nilContentBuilder()
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

探してみると、Package にして公開している人もいたのでSwiftUI-IfLetもぜひ

参考

# ShareSheet(UIActivityViewController)

コンテンツのシェアを行うためにはUIActivityViewControllerを利用するのが楽ですよね。

既存の UIKit を利用するにはUIViewControllerRepresentableを使います。

しかし、Catalyst 対応した際に

最終的に下記のようなコードになり、controllerを外部から参照できるようにしました。

ShareSheet.swift
struct ShareSheet: UIViewControllerRepresentable {
  typealias Callback = (_ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error?) -> Void

  let activityItems: [Any]
  let applicationActivities: [UIActivity]? = nil
  let excludedActivityTypes: [UIActivity.ActivityType]? = nil
  let callback: Callback? = nil

  var controller: UIActivityViewController {
    .init(
      activityItems: activityItems,
      applicationActivities: applicationActivities
    )
  }

  func makeUIViewController(context: Context) -> UIActivityViewController {
    controller.excludedActivityTypes = excludedActivityTypes
    controller.completionWithItemsHandler = callback
    return controller
  }

  func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

macOS の場合には直接呼び出すことで解決しています。 ただし、

#if targetEnvironment(macCatalyst)
UIApplication.shared.windows[0].rootViewController!.present(self.shareSheet.controller, animated: true)
#else
showShareSheet() // ShareSheetを表示するためのコード
#endif
1
2
3
4
5

参考: How to popup a document picker in ios and macos using catalyst

# おわり

以上、SwiftUI における Tips を 4 つ紹介してきました。

今後も SwiftUI やその他の技術について発信していきます。

よかったらシェアしてね 😉