Reputation: 6605
I'm trying to figure out the correct way to conditionally include a view with swiftui. I wasn't able to use the if
directly inside of a view and had to use a
stack view to do it.
This works but there seems like there would be a cleaner way.
var body: some View {
HStack() {
if keychain.get("api-key") != nil {
TabView()
} else {
LoginView()
}
}
}
Upvotes: 151
Views: 124702
Reputation: 1607
Here’s a very simple to use modifier which uses a boolean test to decide if a view will be rendered. Unlike other solutions posted here it doesn’t rely on the use of ÀnyView
. This is how to use it:
var body: some View {
VStack {
FooView()
.onlyIf(someCondition)
}
}
This reads nicer than the default if
… then
construct as it removes the additional indentation.
To replace an if
… then
… else
construct, this is the obvious solution:
var body: some View {
VStack {
FooView()
.onlyIf(someCondition)
BarView()
.onlyIf(!someCondition)
}
}
This is the definition of the onlyIf
modifier:
struct OnlyIfModifier: ViewModifier {
var condition: Bool
func body(content: Content) -> some View {
if condition {
content
}
}
}
extension View {
func onlyIf(_ condition: Bool) -> some View {
modifier(OnlyIfModifier(condition: condition))
}
}
Give it a try – it will surely clean up your code and improve overall readability.
Upvotes: 2
Reputation: 6849
If the error message is
Closure containing control flow statement cannot be used with function builder 'ViewBuilder'
Just hide the complexity of the control flow from the ViewBuilder:
This works:
struct TestView: View {
func hiddenComplexControlflowExpression() -> Bool {
// complex condition goes here, like "if let" or "switch"
return true
}
var body: some View {
HStack() {
if hiddenComplexControlflowExpression() {
Text("Hello")
} else {
Image("test")
}
if hiddenComplexControlflowExpression() {
Text("Without else")
}
}
}
}
Upvotes: 4
Reputation: 4336
I needed to embed a view inside another conditionally, so I ended up creating a convenience if
function:
extension View {
@ViewBuilder
func `if`<Content: View>(_ conditional: Bool, content: (Self) -> Content) -> some View {
if conditional {
content(self)
} else {
self
}
}
}
This does return an AnyView, which is not ideal but feels like it is technically correct because you don't really know the result of this during compile time.
In my case, I needed to embed the view inside a ScrollView, so it looks like this:
var body: some View {
VStack() {
Text("Line 1")
Text("Line 2")
}
.if(someCondition) { content in
ScrollView(.vertical) { content }
}
}
But you could also use it to conditionally apply modifiers too:
var body: some View {
Text("Some text")
.if(someCondition) { content in
content.foregroundColor(.red)
}
}
UPDATE: Please read the drawbacks of using conditional modifiers before using this: https://www.objc.io/blog/2021/08/24/conditional-view-modifiers/
Upvotes: 87
Reputation: 115
If you want to navigate to two different views using NavigationLink, you can navigate using ternary operator.
let profileView = ProfileView()
.environmentObject(profileViewModel())
.navigationBarTitle("\(user.fullName)", displayMode: .inline)
let otherProfileView = OtherProfileView(data: user)
.environmentObject(profileViewModel())
.navigationBarTitle("\(user.fullName)", displayMode: .inline)
NavigationLink(destination: profileViewModel.userName == user.userName ? AnyView(profileView) : AnyView(otherProfileView)) {
HStack {
Text("Navigate")
}
}
Upvotes: 0
Reputation: 416
Extension with the condition param works well for me (iOS 14):
import SwiftUI
extension View {
func showIf(condition: Bool) -> AnyView {
if condition {
return AnyView(self)
}
else {
return AnyView(EmptyView())
}
}
}
Example usage:
ScrollView { ... }.showIf(condition: shouldShow)
Upvotes: 4
Reputation: 6500
Use Group instead of HStack
var body: some View {
Group {
if keychain.get("api-key") != nil {
TabView()
} else {
LoginView()
}
}
}
Upvotes: 2
Reputation: 61
I extended @gabriellanata's answer for up to two conditions. You can add more if needed. You use it like this:
Text("Hello")
.if(0 == 1) { $0 + Text("World") }
.elseIf(let: Int("!")?.description) { $0 + Text($1) }
.else { $0.bold() }
The code:
extension View {
func `if`<TrueContent>(_ condition: Bool, @ViewBuilder transform: @escaping (Self) -> TrueContent)
-> ConditionalWrapper1<Self, TrueContent> where TrueContent: View {
ConditionalWrapper1<Self, TrueContent>(content: { self },
conditional: Conditional<Self, TrueContent>(condition: condition,
transform: transform))
}
func `if`<TrueContent: View, Item>(`let` item: Item?, @ViewBuilder transform: @escaping (Self, Item) -> TrueContent)
-> ConditionalWrapper1<Self, TrueContent> {
if let item = item {
return self.if(true, transform: {
transform($0, item)
})
} else {
return self.if(false, transform: {
transform($0, item!)
})
}
}
}
struct Conditional<Content: View, Trans: View> {
let condition: Bool
let transform: (Content) -> Trans
}
struct ConditionalWrapper1<Content: View, Trans1: View>: View {
var content: () -> Content
var conditional: Conditional<Content, Trans1>
func elseIf<Trans2: View>(_ condition: Bool, @ViewBuilder transform: @escaping (Content) -> Trans2)
-> ConditionalWrapper2<Content, Trans1, Trans2> {
ConditionalWrapper2(content: content,
conditionals: (conditional,
Conditional(condition: condition,
transform: transform)))
}
func elseIf<Trans2: View, Item>(`let` item: Item?, @ViewBuilder transform: @escaping (Content, Item) -> Trans2)
-> ConditionalWrapper2<Content, Trans1, Trans2> {
let optionalConditional: Conditional<Content, Trans2>
if let item = item {
optionalConditional = Conditional(condition: true) {
transform($0, item)
}
} else {
optionalConditional = Conditional(condition: false) {
transform($0, item!)
}
}
return ConditionalWrapper2(content: content,
conditionals: (conditional, optionalConditional))
}
func `else`<ElseContent: View>(@ViewBuilder elseTransform: @escaping (Content) -> ElseContent)
-> ConditionalWrapper2<Content, Trans1, ElseContent> {
ConditionalWrapper2(content: content,
conditionals: (conditional,
Conditional(condition: !conditional.condition,
transform: elseTransform)))
}
var body: some View {
Group {
if conditional.condition {
conditional.transform(content())
} else {
content()
}
}
}
}
struct ConditionalWrapper2<Content: View, Trans1: View, Trans2: View>: View {
var content: () -> Content
var conditionals: (Conditional<Content, Trans1>, Conditional<Content, Trans2>)
func `else`<ElseContent: View>(@ViewBuilder elseTransform: (Content) -> ElseContent) -> some View {
Group {
if conditionals.0.condition {
conditionals.0.transform(content())
} else if conditionals.1.condition {
conditionals.1.transform(content())
} else {
elseTransform(content())
}
}
}
var body: some View {
self.else { $0 }
}
}
Upvotes: 2
Reputation: 1571
I chose to solve this by creating a modifier that makes a view "visible" or "invisible". The implementation looks like the following:
import Foundation
import SwiftUI
public extension View {
/**
Returns a view that is visible or not visible based on `isVisible`.
*/
func visible(_ isVisible: Bool) -> some View {
modifier(VisibleModifier(isVisible: isVisible))
}
}
fileprivate struct VisibleModifier: ViewModifier {
let isVisible: Bool
func body(content: Content) -> some View {
Group {
if isVisible {
content
} else {
EmptyView()
}
}
}
}
Then to use it to solve your example, you would simply invert the isVisible
value as seen here:
var body: some View {
HStack() {
TabView().visible(keychain.get("api-key") != nil)
LoginView().visible(keychain.get("api-key") == nil)
}
}
I have considered wrapping this into some kind of an "If" view that would take two views, one when the condition is true and one when the condition is false, but I decided that my present solution is both more general and more readable.
Upvotes: 3
Reputation: 7900
How about that?
I have a conditional contentView, which either is a text or an icon. I solved the problem like this. Comments are very appreciated, since I don't know if this is really "swifty" or just a "hack", but it works:
private var contentView : some View {
switch kind {
case .text(let text):
let textView = Text(text)
.font(.body)
.minimumScaleFactor(0.5)
.padding(8)
.frame(height: contentViewHeight)
return AnyView(textView)
case .icon(let iconName):
let iconView = Image(systemName: iconName)
.font(.title)
.frame(height: contentViewHeight)
return AnyView(iconView)
}
}
Upvotes: 1
Reputation: 1830
Anyway, the issue still exists. Thinking mvvm-like all examples on that page breaks it. Logic of UI contains in View. In all cases is not possible to write unit-test to cover logic.
PS. I am still can't solve this.
UPDATE
I am ended with solution,
View file:
import SwiftUI
struct RootView: View {
@ObservedObject var viewModel: RatesListViewModel
var body: some View {
viewModel.makeView()
}
}
extension RatesListViewModel {
func makeView() -> AnyView {
if isShowingEmpty {
return AnyView(EmptyListView().environmentObject(self))
} else {
return AnyView(RatesListView().environmentObject(self))
}
}
}
Upvotes: 13
Reputation:
The simplest way to avoid using an extra container like HStack
is to annotate your body
property as @ViewBuilder
, like this:
@ViewBuilder
var body: some View {
if user.isLoggedIn {
MainView()
} else {
LoginView()
}
}
Upvotes: 192
Reputation: 1284
Previous answers were correct, however, I would like to mention, you may use optional views inside you HStacks. Lets say you have an optional data eg. the users address. You may insert the following code:
// works!!
userViewModel.user.address.map { Text($0) }
Instead of the other approach:
// same logic, won't work
if let address = userViewModel.user.address {
Text(address)
}
Since it would return an Optional text, the framework handles it fine. This also means, using an expression instead of the if statement is also fine, like:
// works!!!
keychain.get("api-key") != nil ? TabView() : LoginView()
In your case, the two can be combined:
keychain.get("api-key").map { _ in TabView() } ?? LoginView()
Using beta 4
Upvotes: 2
Reputation: 8183
Another approach using ViewBuilder (which relies on the mentioned ConditionalContent
)
buildEither + optional
import PlaygroundSupport
import SwiftUI
var isOn: Bool?
struct TurnedOnView: View {
var body: some View {
Image(systemName: "circle.fill")
}
}
struct TurnedOffView: View {
var body: some View {
Image(systemName: "circle")
}
}
struct ContentView: View {
var body: some View {
ViewBuilder.buildBlock(
isOn == true ?
ViewBuilder.buildEither(first: TurnedOnView()) :
ViewBuilder.buildEither(second: TurnedOffView())
)
}
}
let liveView = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = liveView
(There's also buildIf, but I couldn't figure out its syntax yet. ¯\_(ツ)_/¯
)
One could also wrap the result
View
intoAnyView
import PlaygroundSupport
import SwiftUI
let isOn: Bool = false
struct TurnedOnView: View {
var body: some View {
Image(systemName: "circle.fill")
}
}
struct TurnedOffView: View {
var body: some View {
Image(systemName: "circle")
}
}
struct ContentView: View {
var body: AnyView {
isOn ?
AnyView(TurnedOnView()) :
AnyView(TurnedOffView())
}
}
let liveView = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = liveView
But it kinda feels wrong...
Both examples produce the same result:
Upvotes: 4
Reputation: 6605
Based on the comments I ended up going with this solution that will regenerate the view when the api key changes by using @EnvironmentObject.
UserData.swift
import SwiftUI
import Combine
import KeychainSwift
final class UserData: BindableObject {
let didChange = PassthroughSubject<UserData, Never>()
let keychain = KeychainSwift()
var apiKey : String? {
get {
keychain.get("api-key")
}
set {
if let newApiKey : String = newValue {
keychain.set(newApiKey, forKey: "api-key")
} else {
keychain.delete("api-key")
}
didChange.send(self)
}
}
}
ContentView.swift
import SwiftUI
struct ContentView : View {
@EnvironmentObject var userData: UserData
var body: some View {
Group() {
if userData.apiKey != nil {
TabView()
} else {
LoginView()
}
}
}
}
Upvotes: 6
Reputation: 17231
You didn't include it in your question but I guess the error you're getting when going without the stack is the following?
Function declares an opaque return type, but has no return statements in its body from which to infer an underlying type
The error gives you a good hint of what's going on but in order to understand it, you need to understand the concept of opaque return types. That's how you call the types prefixed with the some
keyword. I didn't see any Apple engineers going deep into that subject at WWDC (maybe I missed the respective talk?), which is why I did a lot of research myself and wrote an article on how these types work and why they are used as return types in SwiftUI.
There is also a detailed technical explanation in another
If you want to fully understand what's going on I recommend reading both.
As a quick explanation here:
General Rule:
Functions or properties with an opaque result type (
some Type
)
must always return the same concrete type.
In your example, your body
property returns a different type, depending on the condition:
var body: some View {
if someConditionIsTrue {
TabView()
} else {
LoginView()
}
}
If someConditionIsTrue
, it would return a TabView
, otherwise a LoginView
. This violates the rule which is why the compiler complains.
If you wrap your condition in a stack view, the stack view will include the concrete types of both conditional branches in its own generic type:
HStack<ConditionalContent<TabView, LoginView>>
As a consequence, no matter which view is actually returned, the result type of the stack will always be the same and hence the compiler won't complain.
There is actually a view component SwiftUI provides specifically for this use case and it's actually what stacks use internally as you can see in the example above:
It has the following generic type, with the generic placeholder automatically being inferred from your implementation:
ConditionalContent<TrueContent, FalseContent>
I recommend using that view container rather that a stack because it makes its purpose semantically clear to other developers.
Upvotes: 39