SwiftUI : How do you display a tooltip / hint on hover? - tooltip

how to display tooltip / hint on some view?
As example, on the button.

SwiftUI 2.0
As simple as
Button("Action") { }
.help("Just do something")
Button("Action") { }
.help(Text("Just do something"))

2020 | SwiftUI 1 and 2 both
In swiftUI 2:
Toggle("...", isOn: $isOn)
.help("this is tooltip")
In swiftUI 1 there is really no native way to create a tooltip.
But here is a solution also for this:
import SwiftUI
#available(OSX 10.15, *)
public extension View {
func tooltip(_ toolTip: String) -> some View {
self.overlay(TooltipView(toolTip))
}
}
#available(OSX 10.15, *)
private struct TooltipView: NSViewRepresentable {
let toolTip: String
init(_ toolTip: String) {
self.toolTip = toolTip
}
func makeNSView(context: NSViewRepresentableContext<TooltipView>) -> NSView {
NSView()
}
func updateNSView(_ nsView: NSView, context: NSViewRepresentableContext<TooltipView>) {
nsView.toolTip = self.toolTip
}
}
and usage:
Text("some text with tooltip")
.tooltip("some tooltip")

Thanks to both Andrew and Sorin for the solution direction. The presented solutions mostly worked but when I used them they totally messed up the layout. It turns out that the Tooltip has its own size, frame etc. which isn't automatically matching the content.
In theory I could address those problems by using fixed frames etc. but that did not seem the right direction to me.
I have come up with the following (slightly more complex) but easy to use solution which doesn't have these drawbacks.
extension View {
func tooltip(_ tip: String) -> some View {
background(GeometryReader { childGeometry in
TooltipView(tip, geometry: childGeometry) {
self
}
})
}
}
private struct TooltipView<Content>: View where Content: View {
let content: () -> Content
let tip: String
let geometry: GeometryProxy
init(_ tip: String, geometry: GeometryProxy, #ViewBuilder content: #escaping () -> Content) {
self.content = content
self.tip = tip
self.geometry = geometry
}
var body: some View {
Tooltip(tip, content: content)
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
private struct Tooltip<Content: View>: NSViewRepresentable {
typealias NSViewType = NSHostingView<Content>
init(_ text: String?, #ViewBuilder content: () -> Content) {
self.text = text
self.content = content()
}
let text: String?
let content: Content
func makeNSView(context _: Context) -> NSHostingView<Content> {
NSViewType(rootView: content)
}
func updateNSView(_ nsView: NSHostingView<Content>, context _: Context) {
nsView.rootView = content
nsView.toolTip = text
}
}
I have added a GeometryReader to the content of the tooltip and then constrain the size of the Tooltip to the match the size of the content.
To use it:
Toggle("...", isOn: $isOn)
.tooltip("This is my tip")
Addition January 2023
The latest release of SwiftUI requires a small change in the tooltip method. I noticed rapid redrawing problems. By adding a ZStack this problem is addressed:
extension View {
func tooltip(_ tip: String) -> some View {
ZStack {
background(GeometryReader { childGeometry in
TooltipView(tip, geometry: childGeometry) {
self
}
})
self
}
}
}

When the overlay isn't good enough, e.g. you want the tooltip on a control that accepts mouse events (and an overlay would not allow clicks through), such as Toggle, a solution may be to use a Host view that internally includes a NSHostingView itself - that supports a tooltip being an AppKit view - eventually loading further SwiftUI content inside:
struct Tooltip<Content: View>: NSViewRepresentable {
typealias NSViewType = NSHostingView<Content>
init(_ text: String?, #ViewBuilder content: () -> Content) {
self.text = text
self.content = content()
}
let text: String?
let content: Content
func makeNSView(context: NSViewRepresentableContext<Tooltip<Content>>) -> NSViewType {
NSViewType(rootView: content)
}
func updateNSView(_ nsView: NSViewType, context: NSViewRepresentableContext<Tooltip<Content>>) {
nsView.rootView = content
nsView.toolTip = text
}
}
This does have some caveats regarding sizing when used with certain SwiftUI content (and then you may hopefully use fixedSize() or a frame(width:height:) to get it working as you need), but it's otherwise easy to use:
Tooltip("A description") {
Toggle("...", isOn: $isOn)
}

struct TooltipText: View {
#State private var isActive = false
let text: String
let helpText: String
var body: some View {
Text(isActive ? helpText : text)
.padding( isActive ? 6 : 0)
.background(Color.white)
.overlay(
RoundedRectangle(cornerRadius: 3)
.stroke(Color.blue, lineWidth: isActive ? 1 : 0)
)
.animation(.easeOut(duration: 0.2) )
.gesture(DragGesture(minimumDistance: 0)
.onChanged( { _ in isActive = true } )
.onEnded( { _ in isActive = false } )
)
}
}
use:
TooltipText(text: "sum of squares:", helpText: "sum of data (with mean subtracted) squared")

Related

How can I pass 2 content parameters to a SwiftUI View?

I have a Card view that takes in a content parameter to display in the bordered view.
public struct Card<Content: View>: View {
private let content: Content
public init(#ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
content
.padding(16)
.frame(maxWidth: .infinity)
.background(TileShape(cornerRadius: 8, backgroundColor: backgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
What I would like to do is introduce a stacked Card. Maybe something like this:
public struct Card<Content: View>: View {
private let content: Content
private let stackedContent: Content
public init(#ViewBuilder content: () -> Content, #ViewBuilder stackedContent: () -> Content) {
self.content = content()
self.stackedContent = stackedContent()
}
public var body: some View {
ZStack {
content
.padding(16)
.frame(maxWidth: .infinity)
.background(TileShape(cornerRadius: 8, backgroundColor: backgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))
stackedContent
/// put stuff here to align it correctly
}
}
}
While I can create this initializer, the problem comes in trying to give it content.
in my calling code I have
Tile {
Text("Card contents")
}
When I try to introduce the 2nd card, I get segment faults during compilation.
Tile(stackedContent: stackedCard) {
Text("Base Card Contents")
}
#ViewBuilder
var stackedCard: Card<some View> {
Card {
Text("Stacked Card Here")
}
}
Is my goal possible with SwiftUI? I'm limited to using iOS 14 as the target os version.
You might be tempted to ask, "Why not just use 2 Cards at the point of use and align them there?". The answer is, I am trying to replicate something in UIKit in a transition to SwiftUI.
You need to give different content types for generics (because in general they can differ)
So a fix would be
public struct Card<Content1: View, Content2: View>: View {
private let content: Content1
private let stackedContent: Content2
public init(#ViewBuilder content: () -> Content1, #ViewBuilder stackedContent: () -> Content2) {
self.content = content()
self.stackedContent = stackedContent()
}
// ...
}

How To Switch A To A View In SwiftUI? [duplicate]

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()
}
}
}
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()
}
}
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/
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.
🔗 What’s this “some” in SwiftUI?
There is also a detailed technical explanation in another
🔗 Stackoverflow post on opaque result types
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.
💡 Supplemental:
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:
ConditionalContent
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.
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))
}
}
}
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()
}
}
}
}
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 into AnyView
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:
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.
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)
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")
}
}
}
}
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
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 }
}
}
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)
}
}
Use Group instead of HStack
var body: some View {
Group {
if keychain.get("api-key") != nil {
TabView()
} else {
LoginView()
}
}
}
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.
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")
}
}

SwiftUI, Passing a View as params in #Viewbuilder

My curiosity takes me to pass a View type as parameter to #ViewBuilder. Passing a Model/Primitive type as param in #ViewBuilder is perfectly valid.
As shown below code.
struct TestView<Content: View>: View {
let content: (String) -> Content
init(#ViewBuilder content: #escaping (String) -> Content) {
self.content = content
}
var body: some View {
content("Some text")
}
}
struct ContentTestView: View {
var body: some View {
TestView {
Text("\($0)")
}
}
}
In place of String in
let content: (String) -> Content
If I try to pass a SwiftUI View type, then Compiler is not happy with it.
let content: (View) -> Content
Even though params for #ViewBuilder accepts custom Protocol type like Searchable but not View protocol.
compiler tell me this Protocol 'View' can only be used as a generic constraint because it has Self or associated type requirements
My whole idea is that content can be allowed to hold Section/List/Text in it.
Edit: I expected code like below.
struct TestView<Content: View>: View {
let content: (View) -> Content
init(#ViewBuilder content: #escaping (View) -> Content) {
self.content = content
}
var body: some View {
content(
List {
ForEach(0..<10) { i in
Text(\(i))
}
}
)
}
}
struct ContentTestView: View {
var body: some View {
TestView { viewbody -> _ in
Section(header: Text("Header goes here")) {
viewbody
}
}
}
}
Any way can I achieve this ?
The possible solution is to use AnyView, like
struct TestView<Content: View>: View {
let content: (AnyView) -> Content
init(#ViewBuilder content: #escaping (AnyView) -> Content) {
self.content = content
}
var body: some View {
content(AnyView(
Text("Demo") // << put here any view hierarchy
))
}
}

Optional #ViewBuilder closure

Is it possible in SwiftUI to have an optional #ViewBuilder closure? For example, let's say I want to develop a custom view that takes two view builder closures like this:
import SwiftUI
struct TopAndBottomView<Content>: View where Content: View {
let topContent: () -> Content
let bottomContent: () -> Content
init(#ViewBuilder topContent: #escaping () -> Content, #ViewBuilder bottomContent: #escaping () -> Content) {
self.topContent = topContent
self.bottomContent = bottomContent
}
var body: some View {
VStack {
topContent()
Spacer()
bottomContent()
}
}
}
struct TopAndBottomView_Previews: PreviewProvider {
static var previews: some View {
TopAndBottomView(topContent: {
Text("TOP")
}, bottomContent: {
Text("BOTTOM")
})
}
}
But I'd like the bottom view to be optional. I tried with:
struct TopAndBottomView<Content>: View where Content: View {
let topContent: () -> Content
let bottomContent: (() -> Content)?
init(#ViewBuilder topContent: #escaping () -> Content, #ViewBuilder bottomContent: (() -> Content)? = nil) {
self.topContent = topContent
self.bottomContent = bottomContent
}
var body: some View {
VStack {
topContent()
Spacer()
if bottomContent != nil {
bottomContent!()
}
}
}
}
but I get this error:
Function builder attribute 'ViewBuilder' can only be applied to a
parameter of function type.
Thanks.
Taking into account buildIf feature of ViewBuilder the following approach is possible that allows to keep ViewBuilder in init (that is preferable)
Tested & works with Xcode 11.2 / iOS 13.2
struct TopAndBottomView<Content>: View where Content: View {
let topContent: () -> Content
let bottomContent: () -> Content?
init(#ViewBuilder topContent: #escaping () -> Content,
#ViewBuilder bottomContent: #escaping () -> Content? = { nil }) {
self.topContent = topContent
self.bottomContent = bottomContent
}
var body: some View {
VStack {
topContent()
Spacer()
bottomContent()
}
}
}
So works as this one
struct TopAndBottomView_Previews: PreviewProvider {
static var previews: some View {
TopAndBottomView(topContent: {
Text("TOP")
}, bottomContent: {
Text("BOTTOM")
})
}
}
and this one
struct TopAndBottomView_Previews: PreviewProvider {
static var previews: some View {
TopAndBottomView(topContent: {
Text("TOP")
})
}
}
#JoeBayLD asked:
How would you do this if the topContent and bottomContent are different view types? I made a new generic property but when using the default 'nil' argument, any callers can't infer the content type
You can make both ViewBuilder parameters non-optional, and then handle the "no bottom content" case by making an extension where BottomContent == EmptyView:
struct TopAndBottomView<TopContent: View, BottomContent: View>: View {
let topContent: TopContent
let bottomContent: BottomContent
init(#ViewBuilder topContent: () -> TopContent,
#ViewBuilder bottomContent: () -> BottomContent) {
self.topContent = topContent()
self.bottomContent = bottomContent()
}
var body: some View {
VStack {
topContent
Spacer()
bottomContent
}
}
}
extension TopAndBottomView where BottomContent == EmptyView {
init(#ViewBuilder topContent: () -> TopContent) {
self.init(topContent: topContent, bottomContent: { EmptyView() })
}
}
// usage
TopAndBottomView(topContent: { Text("hello") })
TopAndBottomView(topContent: { Text("hello") }, bottomContent: { Text("world") })
In this fantastic post from Sundell, he suggests that we build a custom struct Unwrap to unwrap an optional value and turn it into a View, the following code is what he did in that post:
import SwiftUI
/// # Unwrap
/// unwraps a value (of type `Value`) and turns it
/// into `some View` (== `Optional<Content>`).
struct Unwrap<Value, Content: View>: View {
private let value : Value? // value to be unwrapped
private let content: (Value) -> Content // closure: turn `Value` into `Content`
init(
_ value: Value?,
#ViewBuilder content: #escaping (Value) -> Content // ⭐️ #ViewBuilder
) {
self.value = value
self.content = content
}
var body: some View {
// map: (by the closure `content`)
// nil (Optional<Value>.none) -> nil (Optional<Content>.none)
// Optional<Value>.some(Value) -> Optional<Content>.some(Content)
value.map(content) // Optional<Content>
}
}
And then I wrote some code to demonstrate how we could use Unwrap to construct our views:
import SwiftUI
// MyView
struct MyView: View {
#State private var isValue1Nil = false
#State private var isValue2Nil = false
var value1: Int? { isValue1Nil ? nil : 1}
var value2: Int? { isValue2Nil ? nil : 2}
var body: some View {
VStack {
// stack of `Unwrap`s
VStack {
// ⭐️ `Unwrap` used here.
Unwrap(value1) {
Color.red.overlay(Text("\($0)"))
}
Unwrap(value2) {
Color.orange.overlay(Text("\($0)"))
}
}.border(Color.blue, width: 3)
// toggles
HStack {
Toggle(isOn: $isValue1Nil) {
Text("value1 is nil")
}
Toggle(isOn: $isValue2Nil) {
Text("value2 is nil")
}
Spacer()
}
.padding()
.overlay(Rectangle().stroke(Color.gray, style: .init(dash: [6])))
} // VStack (container)
.padding()
.border(Color.gray, width: 3)
}
}
And the result is as follows:
----[ edited ]----
Or alternatively, we can make a View extension to do the job:
// view.ifLet(_:then:)
extension View {
#ViewBuilder func ifLet<Value, Content: View>(
_ value: Value?,
#ViewBuilder then modifySelfWithValue: (Self, Value) -> Content
) -> some View {
if value != nil {
modifySelfWithValue(self, value!)
} else { self }
}
}
The following is another demo on how to use this extension:
struct ContentView: View {
#State private var isNil = false
var value: Int? { isNil ? nil : 2 }
var body: some View {
VStack {
Color.red.overlay(Text("1"))
// ⭐️ view.ifLet(_:then:)
.ifLet(value) { (thisView, value) in
// construct new view with `thisView` and `value`
VStack {
thisView
Color.orange.overlay(Text("\(value)"))
}
} // view modified by `ifLet`
.border(Color.blue, width: 3)
// toggles
Toggle(isOn: $isNil) { Text("value is nil") }
.padding()
.overlay(Rectangle().stroke(Color.gray, style: .init(dash: [6])))
} // VStack (container)
.padding()
.border(Color.gray, width: 3).frame(height: 300)
}
}
and the result is:
Set the default value for #ViewBuilder View, in order to achieve what you're looking for:
struct AlertView<InputFields: View, Actions: View>: View {
private let inputFields: InputFields
private let actions: Actions
init(
#ViewBuilder inputFields: () -> InputFields = { EmptyView() }, <=== HERE
#ViewBuilder actions: () -> Actions = { EmptyView() } <=== HERE
) {
self.inputFields = inputFields()
self.actions = actions()
}
var body: some View {
VStack{
inputFields
actions
}
}
}
Instead of an optional #ViewBuilder parameter, a workaround is to set the default value of the parameter to EmptyView(). While this is not possible directly in the SwiftUI view struct, we can add an extension with an init() as follows:
/// View with mandatory icon view builder.
struct Hint<IconView: View>: View {
var message: String
#ViewBuilder var icon: IconView
var body: some View {
HStack {
icon.frame(width: 40, height: 40)
Text(message)
}
}
}
/// View Extensions that sets the icon view builder default to EmptyView().
extension Hint<EmptyView> {
init(message: String) {
self.message = message
self.icon = EmptyView()
}
}
Like this you can use the Hint-View either by including the icon view builder or by leaving it out (in which case the default EmptyView is used):
Hint(message: "This is a warning with icon!", icon: { Image(systemName: .exclamationmarkTriangle) })
Hint(message: "This is a warning with icon!")
It seems that you dont need the #ViewBuilder in your initializer so this would work:
struct TopAndBottomView<Content>: View where Content: View {
let topContent: () -> Content
let bottomContent: (() -> Content)?
init(#ViewBuilder topContent: #escaping () -> Content, bottomContent: (() -> Content)? = nil) {
self.topContent = topContent
self.bottomContent = bottomContent
}
var body: some View {
VStack {
topContent()
Spacer()
if bottomContent != nil {
bottomContent!()
}
}
}
}
And how to use:
TopAndBottomView(topContent: {
Text("top")
})
TopAndBottomView(topContent: {
Text("top")
}, bottomContent: {
Text("optional bottom")
})

SwiftUI NavigationLink loads destination view immediately, without clicking

With following code:
struct HomeView: View {
var body: some View {
NavigationView {
List(dataTypes) { dataType in
NavigationLink(destination: AnotherView()) {
HomeViewRow(dataType: dataType)
}
}
}
}
}
What's weird, when HomeView appears, NavigationLink immediately loads the AnotherView. As a result, all AnotherView dependencies are loaded as well, even though it's not visible on the screen yet. The user has to click on the row to make it appear.
My AnotherView contains a DataSource, where various things happen. The issue is that whole DataSource is loaded at this point, including some timers etc.
Am I doing something wrong..? How to handle it in such way, that AnotherView gets loaded once the user presses on that HomeViewRow?
The best way I have found to combat this issue is by using a Lazy View.
struct NavigationLazyView<Content: View>: View {
let build: () -> Content
init(_ build: #autoclosure #escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
Then the NavigationLink would look like this. You would place the View you want to be displayed inside ()
NavigationLink(destination: NavigationLazyView(DetailView(data: DataModel))) { Text("Item") }
EDIT: See #MwcsMac's answer for a cleaner solution which wraps View creation inside a closure and only initializes it once the view is rendered.
It takes a custom ForEach to do what you are asking for since the function builder does have to evaluate the expression
NavigationLink(destination: AnotherView()) {
HomeViewRow(dataType: dataType)
}
for each visible row to be able to show HomeViewRow(dataType:), in which case AnotherView() must be initialized too.
So to avoid this a custom ForEach is necessary.
import SwiftUI
struct LoadLaterView: View {
var body: some View {
HomeView()
}
}
struct DataType: Identifiable {
let id = UUID()
var i: Int
}
struct ForEachLazyNavigationLink<Data: RandomAccessCollection, Content: View, Destination: View>: View where Data.Element: Identifiable {
var data: Data
var destination: (Data.Element) -> (Destination)
var content: (Data.Element) -> (Content)
#State var selected: Data.Element? = nil
#State var active: Bool = false
var body: some View {
VStack{
NavigationLink(destination: {
VStack{
if self.selected != nil {
self.destination(self.selected!)
} else {
EmptyView()
}
}
}(), isActive: $active){
Text("Hidden navigation link")
.background(Color.orange)
.hidden()
}
List{
ForEach(data) { (element: Data.Element) in
Button(action: {
self.selected = element
self.active = true
}) { self.content(element) }
}
}
}
}
}
struct HomeView: View {
#State var dataTypes: [DataType] = {
return (0...99).map{
return DataType(i: $0)
}
}()
var body: some View {
NavigationView{
ForEachLazyNavigationLink(data: dataTypes, destination: {
return AnotherView(i: $0.i)
}, content: {
return HomeViewRow(dataType: $0)
})
}
}
}
struct HomeViewRow: View {
var dataType: DataType
var body: some View {
Text("Home View \(dataType.i)")
}
}
struct AnotherView: View {
init(i: Int) {
print("Init AnotherView \(i.description)")
self.i = i
}
var i: Int
var body: some View {
print("Loading AnotherView \(i.description)")
return Text("hello \(i.description)").onAppear {
print("onAppear AnotherView \(self.i.description)")
}
}
}
I had the same issue where I might have had a list of 50 items, that then loaded 50 views for the detail view that called an API (which resulted in 50 additional images being downloaded).
The answer for me was to use .onAppear to trigger all logic that needs to be executed when the view appears on screen (like setting off your timers).
struct AnotherView: View {
var body: some View {
VStack{
Text("Hello World!")
}.onAppear {
print("I only printed when the view appeared")
// trigger whatever you need to here instead of on init
}
}
}
For iOS 14 SwiftUI.
Non-elegant solution for lazy navigation destination loading, using view modifier, based on this post.
extension View {
func navigate<Value, Destination: View>(
item: Binding<Value?>,
#ViewBuilder content: #escaping (Value) -> Destination
) -> some View {
return self.modifier(Navigator(item: item, content: content))
}
}
private struct Navigator<Value, Destination: View>: ViewModifier {
let item: Binding<Value?>
let content: (Value) -> Destination
public func body(content: Content) -> some View {
content
.background(
NavigationLink(
destination: { () -> AnyView in
if let value = self.item.wrappedValue {
return AnyView(self.content(value))
} else {
return AnyView(EmptyView())
}
}(),
isActive: Binding<Bool>(
get: { self.item.wrappedValue != nil },
set: { newValue in
if newValue == false {
self.item.wrappedValue = nil
}
}
),
label: EmptyView.init
)
)
}
}
Call it like this:
struct ExampleView: View {
#State
private var date: Date? = nil
var body: some View {
VStack {
Text("Source view")
Button("Send", action: {
self.date = Date()
})
}
.navigate(
item: self.$date,
content: {
VStack {
Text("Destination view")
Text($0.debugDescription)
}
}
)
}
}
I was recently struggling with this issue (for a navigation row component for forms), and this did the trick for me:
#State private var shouldShowDestination = false
NavigationLink(destination: DestinationView(), isActive: $shouldShowDestination) {
Button("More info") {
self.shouldShowDestination = true
}
}
Simply wrap a Button with the NavigationLink, which activation is to be controlled with the button.
Now, if you're to have multiple button+links within the same view, and not an activation State property for each, you should rely on this initializer
/// Creates an instance that presents `destination` when `selection` is set
/// to `tag`.
public init<V>(destination: Destination, tag: V, selection: Binding<V?>, #ViewBuilder label: () -> Label) where V : Hashable
https://developer.apple.com/documentation/swiftui/navigationlink/3364637-init
Along the lines of this example:
struct ContentView: View {
#State private var selection: String? = nil
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: Text("Second View"), tag: "Second", selection: $selection) {
Button("Tap to show second") {
self.selection = "Second"
}
}
NavigationLink(destination: Text("Third View"), tag: "Third", selection: $selection) {
Button("Tap to show third") {
self.selection = "Third"
}
}
}
.navigationBarTitle("Navigation")
}
}
}
More info (and the slightly modified example above) taken from https://www.hackingwithswift.com/articles/216/complete-guide-to-navigationview-in-swiftui (under "Programmatic navigation").
Alternatively, create a custom view component (with embedded NavigationLink), such as this one
struct FormNavigationRow<Destination: View>: View {
let title: String
let destination: Destination
var body: some View {
NavigationLink(destination: destination, isActive: $shouldShowDestination) {
Button(title) {
self.shouldShowDestination = true
}
}
}
// MARK: Private
#State private var shouldShowDestination = false
}
and use it repeatedly as part of a Form (or List):
Form {
FormNavigationRow(title: "One", destination: Text("1"))
FormNavigationRow(title: "Two", destination: Text("2"))
FormNavigationRow(title: "Three", destination: Text("3"))
}
In the destination view you should listen to the event onAppear and put there all code that needs to be executed only when the new screen appears. Like this:
struct DestinationView: View {
var body: some View {
Text("Hello world!")
.onAppear {
// Do something important here, like fetching data from REST API
// This code will only be executed when the view appears
}
}
}

Resources