SwiftUI extension for OS-specific view modifiers that seems too arcane to implement

You know how SwiftUI views are customized via "view modifiers" like

Rectangle()
    .foregroundColor(.appleRainbow)

right? These are methods which return a new view with the modifier applied, so you can chain them with other modifiers etc.

You may also know that SwiftUI is great for building cross-platform apps with minimal OS-specific changes.

Though, if I want to make something that works differently on iOS and macOS, I have to either write 2 different views with a lot of code duplication and cordon off the OS-specific code in #if os(...) blocks, even just to use 1 OS-specific modifier (such as .onCommand(_:perform:) for macOS menus or .onPlayPauseCommand(perform:) for the Apple TV remote.)

So I just thought, wouldn't it be great if there were view modifiers like

Rectangle()
    .iOS(fill(.unicornPuke))
    .macOS(foregroundColor(.aquaBeige))
    .tvOS(focusable())

and so on. So I fired up a New File and started crackin:

import SwiftUI

public extension View {
    func iOS(_ modifier: ?) -> Self {
        #if os(iOS)
            return self.modifier(...) // ?
        #else
            return self
        #endif
    }
}

That's about as far as I got. To do this, we need to

  • Pass one of the methods available on self as an argument.
  • Include any arguments for that method.
  • Run that method on self

I have a feeling it might involve key paths, @dynamicMemberLookup and/or @dynamicCallable but I cannot at the moment figure out how to put these together.

Is something like this even possible?

Note the syntax should be simply .OS(someModifier(someArgs: etc)) and not .OS { $0.someModifier(someArgs: etc) } :slight_smile:

Update: If anyone's interested, I managed to implement it the not-as-elegant way:

@inlinable
func iOS <ModifiedViewType: View> (modifier: (Self) -> ModifiedViewType) -> some View {
    #if os(iOS)
        return modifier(self)
    #else
        return self
    #endif
}

You can’t get that exact syntax, because we have no way in the language of writing “a bit of code” which gets applied later with some parameter.

We have closures, which accept and use explicit parameters, and we even have autoclosures - “a bit of code” that you can write as a parameter, but which magically gets turned in to closure. For example, in release builds, the code you write in an “assert” call won’t just not crash if it fails - it won’t even be evaluated in the first place. That’s the closest thing to what you’re looking for, but autoclosures don’t support parameters.

We also have KeyPaths, but they don’t work for functions. Everyone loves keypaths though, and we’d like to do a version of them for functions, but that would probably require language infrastructure that won’t exist for some time (variadic genetics)

EDIT: okay, technically you could do it, but it would involve copying all of the modifier members (like fill/foreground) as free functions which return a closure. I’m not sure it’s worth it, but you could do it if you want to.

EDIT 2: oh, no you couldn’t. Closures don’t support opaque types. Ah well.

1 Like

How silly of me. Of course the "solution" I thought I had doesn't work for long:

func tvOSExcluded <ModifiedViewType: View> (modifier: (Self) -> ModifiedViewType) -> some View {
    #if !os(tvOS)
        return modifier(self)
    #else
        return self
    #endif
}

This was supposed to help me write stuff like:

Rectangle()
    .foregroundColor(.blue) // For all systems.
    .imagineALotMoreUniversalModifiers()
    .tvOSExcluded { $0.onTapGesture { print("TAP") } } // .onTapGesture is unavailable on tvOS

but of course I still get the compile-time error about API unavailability. :cry:

So I'm back to the dreaded #if but that doesn't work well either:

Rectangle()
    .foregroundColor(.blue)
    .tvOSExcluded { // ❌ Unable to infer complex closure return type; add explicit type to disambiguate
        #if !os(tvOS)
        $0.onTapGesture { print("TAP") }
        #endif
        }

Any ideas on how to achieve the intent without making it even more cluttered?

sigh.. This seems like a futile exercise.

I just want to be able to write stuff like this:

Rectangle()
    .padding()
    .anotherModifier()
    .yetAnotherModifierThatWorksOnEveryOS()
    .imagineManyMoreUniversalModifiers()
    .conditional {
        #if !os(tvOS)
        // The ONLY modifier that doesn't work on one OS.
        return $0.onTapGesture { print("TAPPED") }
        #else
        return $0
        #endif
    }

because without a construct like that .conditional, I would have to copy the entire hierarchy and duplicate a lot of code with just 1 difference, or create ugly parent+child views where the parent adds that one modifier with an #if / #else.

I tried to implement that with this:

extension View {
    func conditional <ModifiedView: View> (modifier: (Self) -> ModifiedView) -> some View {
        modifier(self)
    }
}

But I get the following error when trying to use .conditional in the manner above:

:x: Unable to infer complex closure return type; add explicit type to disambiguate

Is there a way? Can something like @ViewBuilder help? Should I abandon this quest for a cleaner API?

1 Like
import SwiftUI

extension View {
    #if os(iOS)
    func iOS<Answer>(_ body: (Self) -> Answer) -> Answer { body(self) }
    #else
    func iOS<Answer>(_ body: (Self) -> Answer) -> Self { self }
    #endif

    #if os(tvOS)
    func tvOS<Answer>(_ body: (Self) -> Answer) -> Answer { body(self) }
    #else
    func tvOS<Answer>(_ body: (Self) -> Answer) -> Self { self }
    func focusable() -> Self { self }
    #endif
}

print(Rectangle())
// Output: Rectangle()

print(Rectangle()
    .iOS { $0.fill(Color.purple) }
    .tvOS { $0.focusable() }
)
// Output: _ShapeView<Rectangle, Color>(shape: SwiftUI.Rectangle(), style: purple, fillStyle: SwiftUI.FillStyle(isEOFilled: false, isAntialiased: true))

Note the need to define focusable on non-tvOS platforms to allow the code to compile on all platforms.

1 Like

Yes, this solution is unwieldy because there are many other OS-specific modifiers like that, with arguments etc., and it won’t account for future modifiers.

What do you think about the solution like that? It uses AnyView to make sure the returned type is the same in both branches of the #if

extension View {
    func conditional(closure: (Self) -> AnyView) -> AnyView {
        return closure(self)
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello")
                .padding()
                .background(Color.red)
                .conditional {
                    #if !os(tvOS)
                    return AnyView($0.onTapGesture {
                        print("TAPPED")
                    })
                    #else
                    return AnyView($0)
                    #endif
                }
                .padding()
                .background(Color.blue)
        }
    }
}

I would rather avoid using AnyView if possible. Not only it’s not elegant, I read that it might reduce performance, and it also messes up any following view modifiers, such as the ones only available on Text for example.

2 Likes

Hi there,

Inspired by you guys, I am making my library:

import SwiftUI

public extension View {
    func watchOS<Content: View>(_ modifier: @escaping (Self) -> Content) -> some View {
        #if os(watchOS)
        return modifier(self)
        #else
        return self
        #endif
    }
    
    func iOS<Content: View>(_ modifier: @escaping (Self) -> Content) -> some View {
        #if os(iOS)
        return modifier(self)
        #else
        return self
        #endif
    }

    func tvOS<Content: View>(_ modifier: @escaping (Self) -> Content) -> some View {
        #if os(tvOS)
        return modifier(self)
        #else
        return self
        #endif
    }
    
    func macOS<Content: View>(_ modifier: @escaping (Self) -> Content) -> some View {
        #if os(macOS)
        return modifier(self)
        #else
        return self
        #endif
    }
}```

I settled on a similar implementation:

I went with a more flexible approach, where you can target multiple platform in a single callback:

List {
    // ...
}
.ifOS(.macOS, .tvOS) {
    $0.listStyle(SidebarListStyle())
}

Implementation here: https://stackoverflow.com/a/62099616/64949

1 Like

Looking for a solution, I came up w/ a slightly modified approach:

extension View {
  @inlinable
  func modify<T: View>(@ViewBuilder modifier: ( Self ) -> T) -> T {
    return modifier(self)
  }
}

which can be used like that:

MyView()
  .modify {
    #if os(macOS)
      $0
    #else
      $0.navigationBarTitle("XYZ")
    #endif
  }

I put it online at @sindresorhus's tips page: Tips · Discussion #7 · sindresorhus/swiftui · GitHub

Terms of Service

Privacy Policy

Cookie Policy