👌

[SwiftUI] #availableチェックを楽にする

2022/05/15に公開

SwiftUIのmodifierに@availableがついているとき、ありますよね。たとえばiOS15で追加されたsubmitLabelは次のような定義になっています。

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension View {

    /// Sets the submit label for this view.
    ///
    ///     Form {
    ///         TextField("Username", $viewModel.username)
    ///             .submitLabel(.continue)
    ///         SecureField("Password", $viewModel.password)
    ///             .submitLabel(.done)
    ///     }
    ///
    /// - Parameter submitLabel: One of the cases specified in ``SubmitLabel``.
    public func submitLabel(_ submitLabel: SubmitLabel) -> some View

}

今、iOS13を最低要件としたプロジェクトで、次のようなviewsubmitLabelを追加することを考えます。

var body: some View {
    TextField("Text", $text)
        .someLongMethodChain()
}

このmodifierを使うにはif #available(...)チェックが必要になります。従って素直に書くとこうなります。

var body: some View {
    if #available(iOS 15.0, *) {
        TextField("Text", $text)
            .someLongMethodChain()
	    .submitLabel(.done)
    } else {
        TextField("Text", $text)
            .someLongMethodChain()
    }
}

変更自体はシンプルですが、嫌な点として同じ構造を2度書くことになってしまいました。これは変更するときに厄介になります。

そこで、切り出しを行います。

var textField: some View {
    TextField("Text", $text)
        .someLongMethodChain()
}

var body: some View {
    if #available(iOS 15.0, *) {
        textField
	    .submitLabel(.done)
    } else {
        textField
    }
}

先程よりはマシになりました。

しかし、今度はiOS14から追加されたhogeModifierをくっつけたくなったとします。以下のようになります。

var body: some View {
    if #available(iOS 15.0, *) {
         textField
	     .hogeModifier()
	     .submitLabel(.done)
    } else if #available(iOS 14.0, *) {
         textField
	     .hogeModifier()
    } else {
         textField
    }
}

再び共通の構造が生じてしまったので、切り出すことにします。

var textField: some View {
    TextField("Text", $text)
        .someLongMethodChain()
}

@available(iOS 14.0, *)
var iOS14textField: some View {
     textField
	 .hogeModifier()
}

var body: some View {
    if #available(iOS 15.0, *) {
         iOS14textField
	     .submitLabel(.done)
    } else if #available(iOS 14.0, *) {
         iOS14textField
    } else {
         textField
    }
}

滅多に使わないmodifierならこれでもいいにはいいのですが、submitLabelのような頻繁に使うmodifierではさすがにやっていられない気がします。

解決法

submitLabelを定義し直します。

// SubmitLabel
enum _Available_SubmitLabel {
    case `continue`, done, go, join, next, `return`, route, search, send

    @available(iOS 15, *)
    var label: SubmitLabel {
        switch self {
        case .continue: return .continue
        case .done: return .done
        case .go: return .go
        case .join: return .join
        case .next: return .next
        case .return: return .return
        case .route: return .route
        case .search: return .search
        case .send: return .send
        }
    }
}
extension View {
    @ViewBuilder
    func iOS15_submitLabel(_ label: _Available_SubmitLabel) -> some View {
        if #available(iOS 15, *) {
            self.submitLabel(label.label)
        } else {
            self
        }
    }
}

var body: some View {
    TextField("Text", $text)
        .someLongMethodChain()
        .iOS14_hogeModifier()
	.iOS15_submitLabel(.done)
}

やや力技と感じるかもしれませんが、複数の利点があります。

  • prefixがついていれば動作の推測が十分につきます。
  • availabilityの違うmodifierが複数あってもチェックを分ける必要がありません。
  • プロジェクトのOS要件が変更になってavailabilityチェックが要らなくなった場合、iOS15_submitLabelを一括でsubmitLabelにrenameした上でextensionを消すだけでそのまま動きます。

一括renameの利点は失われますが、modifierによっては次のようなelseブロックを用意しておくとフォールバックもかけるようになります。

extension View {
    @ViewBuilder
    func iOS15_submitLabel<ElseView: View>(_ label: _Available_SubmitLabel, `else`: (Self) -> ElseView) -> some View {
        if #available(iOS 15, *) {
            self.submitLabel(label.label)
        } else {
            else(self)
        }
    }
}

Discussion