[SwiftUI] マルチプラットフォーム 対応しつつ view modifier を適用する

SwiftUI

SwiftUI で使うことができるマルチプラットフォーム対応の View Modifier の作り方を説明します。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Big Sur 11.1
  • Xcode 12.3
  • iOS 14.2

マルチプラットフォーム対応の定義

ここでは、以下を指して マルチプラットフォーム対応と書いてます。

  • 同一のコードで、複数プラットフォームをターゲットとした時に コンパイルエラーにならない
  • 特定のプラットフォームにのみ存在する機能を、そのプラットフォーム上で利用できる

特定のプラットフォームにのみ存在する機能を別プラットフォームにも実装していくことは、想定していません。

背景

SwiftUI になって、Apple の複数のプラットフォームへの対応が容易になってきました。

基本的な View は、複数プラットフォーム対応ですし、個別 OS への対応用のコードは、以下のように #if - #endif で囲うことで、対応できます。

もちろん、#else を使って、プラットフォーム毎に機能を実装することもできます。

iOS 向けの専用コード

#if os(iOS)
何らかの処理
#endif

上記のコードでは、"何らかの処理”の部分は、iOS 向けにのみコンパイル対象となるので、iOS にのみ存在する関数等を使用しても他ターゲットでコンパイルエラーが発生する等もありません。

Swift[Swift] マルチプラットフォームで使えるコードを記述する方法

#if / ViewModifier の問題点

OS の違いは、用意されている API の違いだけではなく、View Modifier の違いもあります。

付与する View Modifier を使い分けるために、#if - #endif を使おうとして以下のようなコードを書くと、問題が発生します。

コンパイルエラー

struct ContentView: View {
    @State private var data = ["Item1", "Item2", "Item3", "Item4"]
    var body: some View {
        VStack {
#if os(iOS)
            EditButton()
#endif
            List {
                ForEach(data, id:\.self) { title in
                    HStack {
                        Text("Title: \(title)")
                    }
                }
                .onDelete(perform: { indexSet in
                    data.remove(at: indexSet.first!)
                })
                .onMove(perform: { indices, newOffset in
                    withAnimation {
                        data.move(fromOffsets: indices, toOffset: newOffset)
                    }
                })
            }
        #if os(iOS)
            .environment(\.editMode, .constant(.active))
        #endif    // <-  エラー  Unexpected platform condition(expected 'ios', 'arch', or 'swift')
        }
    }
}

エラーは、#if - #endif で囲う範囲は、"Statement" である必要があることから来ています。

# ちなみに、#if - #endif を削除すると iOS ターゲットでは問題なく動きます。macOS ターゲットでは、'editMode' is unavailable in macOS というコンパイルエラーとなります。

View Modifier 内部でのマルチプラットフォーム対応

#if - #endif で囲う範囲が "Statement" であることが必要であれば、そのようにすれば良いということになります。

以下のような ViewModifier を作って、その内部で、#if - #endif を使います。

onlyiOSModifier

struct onlyiOSModifier: ViewModifier {
    func body(content: Content) -> some View {
        #if os(iOS)
        return content.environment(\.editMode, .constant(.active))
        #else
        return content
        #endif
    }
}

先ほどのコードは以下のようになります。

example

        List {
          // .. snip
        }
        .modifier(onlyiOSModifier())

こうすることで、macOS ターゲットでもコンパイルエラーとならず、iOS ターゲットでは 指定したかった View Modifier が付与される状態となります。

すこし拡張

一つのビューに、OS 毎に指定したい View Modifier が異なるケースを想定して、少し拡張してみます。

まずは、OS を定義

以下のように OS を定義しました。(システムのどこかに定義されているかも・・・)

define OS

enum OperatingSystem {
    case macOS
    case iOS
    case tvOS
    case watchOS
    #if os(macOS)
    static let current = macOS
    #elseif os(iOS)
    static let current = iOS
    #elseif os(tvOS)
    static let current = tvOS
    #elseif os(watchOS)
    static let current = watchOS
    #else
    #error("Unsupported platform")
    #endif
}

OS を判別して、適切な modifier を適用する

OS を判定して、modifier を起用するようにします。

applyOSModifier

extension View {
    @ViewBuilder
    func applyOSModifier() -> some View {
        switch OperatingSystem.current {
            case .iOS:
                self.modifier(iOSOnlyModifier())
            default:
                self
        }
    }
}

上記では、iOS のみ実装していますが、macOS, tvOS 等も同様です。

使用例は以下のようになります。

使用例

        List {
          // .. snip
        }
        .applyOSModifier()

ここまでくると、マルチプラットフォーム対応のコードを書けた気になります。

注意点

当初、closure として外部から modifier を渡すことを検討していました。

しかし、closure で渡すとすると プラットフォーム依存のコードは、その内部でも #if - #endif を使うことになってしまい、コードが複雑化してしまいます。

ここまで実装してみて分かったのは、現時点では 標準的なコードで マルチプラットフォームを実現することは容易ですが、すこしでもその枠からはみ出ると 必要な手間が大幅に増えるということでした。

まとめ:マルチプラットフォーム対応の View Modifier

マルチプラットフォーム対応の View Modifier
  • view modifier には、#if os(XX) - #endif は使えない
  • view modifier を作って、その内部で #if os(XX) - #endif を使う
  • view extension を作ると、modifier 的に使えて便利

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です