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

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()
}
// ...
}

Related

How to implement Sheets in SwiftUI while using Generics and MVVM?

Context
I am currently working on a system for handling Sheets in SwiftUI. However, I encountered a problem when utilizing the MVVM Design Pattern and generics.
I have a Sheet struct containing the generic View the Sheet should display. I also have an #Published variable in the view model holding the currently active Sheet.
However, obviously, this does not allow sheets with different view types, since I get the following compiler errors:
Error 1: Reference to generic type 'Sheet' requires arguments in <...>
Error 2: 'nil' requires a contextual type
Code
public struct Sheet<Content: View>: Identifiable {
public let id = UUID()
let content: Content
public init(#ViewBuilder content: () -> Content) {
self.content = content()
}
}
public class SheetViewModel: ObservableObject {
public static let shared = SheetViewModel()
private init() {}
#Published var sheet: Sheet? // Error 1
public func present<Content: View>(_ sheet: Sheet<Content>) {
self.sheet = sheet
}
public func present<Content: View>(#ViewBuilder content: () -> Content) {
self.sheet = CRSheet(content: content)
}
public func dismiss() {
self.sheet = nil // Error 2
}
}
public struct SheetViewModifier: ViewModifier {
#ObservedObject private var sheetVM = SheetViewModel.shared
public func body(content: Content) -> some View {
content
.sheet(item: $sheetVM.sheet) { sheet in
sheet.content
}
}
}
Question
How can I resolve the errors, especially, be able to store sheets with different generic views inside the same #Published Variable and later use them inside the view modifier?
My idea was to maybe store the View as any View inside the sheet Struct`. However, I am not sure, how to use it inside the view modifier then?
Please Note: This code is part of a package and therefore needs to be accessed from outside. The generic content therefore must be passed to the present(_) Method.
Ok, so if I'm understanding this correctly, you want to handle the logic for showing different sheets from the Generic View.
First, let's build the GenericView:
struct GenericView<Content: View, FirstSheet: View, SecondSheet: View>: View {
private enum ModalSheet: Identifiable {
var id: Self { return self }
case firstSheet
case secondSheet
}
#State private var showModalSheet: ModalSheet?
private let content: () -> Content
private let firstSheet: () -> FirstSheet
private let secondSheet: () -> SecondSheet
public init(
#ViewBuilder content: #escaping () -> Content,
#ViewBuilder firstSheet: #escaping () -> FirstSheet,
#ViewBuilder secondSheet: #escaping () -> SecondSheet
) {
self.content = content
self.firstSheet = firstSheet
self.secondSheet = secondSheet
}
var body: some View {
ZStack {
content()
VStack {
Button("Show first sheet") {
showModalSheet = .firstSheet
}
Button("Show second sheet") {
showModalSheet = .secondSheet
}
}
}
.sheet(item: $showModalSheet) { modalSheet in
switch modalSheet {
case .firstSheet:
firstSheet()
case .secondSheet:
secondSheet()
}
}
}
}
Note that I created an enum inside GenericView to handle the logic for the different sheets
So now, if I want to use the generic view inside a regular view, I would do the following:
struct ContentView: View {
var body: some View {
GenericView {
Color.yellow.ignoresSafeArea()
} firstSheet: {
Text("Content for First Sheet")
} secondSheet: {
Text("Content for Second Sheet")
}
}
}
This way you can control which sheet you present as well as dismissing them without using a ViewModel.
Is this more or less what you had in mind?

Pass a SwiftUI view that has its own arguments as a variable to another view struct

I'll try to outline my case here, I have a NavigationLink I'm wanting to turn into it's own structure so I can re-use it. The Label inside the NavigationLink is the same for all the cases I'm using, just different text and images. I'm trying to make that new struct that contains the NavigationLink have an argument I use for the destination View of the NavigationLink.
I found this link that got me most of the way, but I just don't seem to be able to get it the last mile.
How to pass one SwiftUI View as a variable to another View struct
Here is the re-usable NavigationLink struct I made:
struct MainMenuButtonView<Content: View>: View {
var imageName: String
var title: String
var description: String
var content: Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content()
}
var body: some View {
VStack {
NavigationLink(destination: content) {
Image(imageName)
.resizable()
.frame(width: 100, height: 100)
Text(title)
.font(.title)
Text(description)
.foregroundColor(Color(UIColor.systemGray2))
.multilineTextAlignment(.center)
.font(.footnote)
.frame(width: 175)
}
.buttonStyle(PlainButtonStyle())
}
}
}
I currently don't get any errors on that part, but that doesn't mean there isn't something wrong with it.
And here is where I'm using it at, currently, I just shown one, but I'll have more once I get it working.
struct MainMenuView: View {
var body: some View {
NavigationView {
MainMenuButtonView(imageName: "List Icon",
title: "Lists",
description: "Auto generated shopping lists by store",
content: TestMainView(testText: "Lists"))
}
.buttonStyle(PlainButtonStyle())
.navigationBarTitle(Text("Main Menu"))
}
}
}
When I leave it as above, it tells me that there is an extra argument and that 'Contect' can't be inferred. Xcode does offer a fix, and it ends up looking like this after I do the fix
MainMenuButtonView<Content: View>(imageName: "List Icon",
But then I get an error that it cannot find 'Content' in scope. I know the main difference between my question and the example I linked above is my View I'm passing in also has arguments. I'm not sure if I'm also supposed to put all the arguments in the callout within the <>.
Thank you for any help you can give.
You need to correct the init in the MainMenuButtonView:
struct MainMenuButtonView<Content: View>: View {
var imageName: String
var title: String
var description: String
var content: () -> Content // change to closure
// add all parameters in the init
init(imageName: String, title: String, description: String, #ViewBuilder content: #escaping () -> Content) {
self.imageName = imageName // assign all the parameters, not only `content`
self.title = title
self.description = description
self.content = content
}
var body: some View {
VStack {
NavigationLink(destination: content()) { // use `content()`
Image(imageName)
.resizable()
.frame(width: 100, height: 100)
Text(title)
.font(.title)
Text(description)
.foregroundColor(Color(UIColor.systemGray2))
.multilineTextAlignment(.center)
.font(.footnote)
.frame(width: 175)
}
.buttonStyle(PlainButtonStyle())
}
}
}
Also, you need to pass a closure to the content parameter (as you indicated in the init):
struct MainMenuView: View {
var body: some View {
NavigationView {
MainMenuButtonView(
imageName: "List Icon",
title: "Lists",
description: "Auto generated shopping lists by store",
content: { TestMainView(testText: "Lists") } // pass as a closure
)
.navigationBarTitle(Text("Main Menu"))
}
}
}

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 : How do you display a tooltip / hint on hover?

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")

Resources