Conditionally wrap code inside view SwiftUI - ios

I want to make my code inside a scrollview based on a boolean
Something like this :
ScrollView{
Vstack {
}
Hstack {
}
}.isWrapped(true)
So if the bool is true, the content is wrapped inside a ScrollView, if not, I just get the content as is. (without the scrollView parent)
I tried with multiples way, the only thing I see as possible is 2 blocks of code, not good practice thought.

One way is to always use a ScrollView, but tell it not to scroll. If you pass an empty set as the first argument to ScrollView, it won't scroll.
ScrollView(shouldScroll ? .vertical : []) {
// content here
}
Another way is to extract the content back out of the ScrollView in your proposed isWrapped modifier, like this:
extension ScrollView {
#ViewBuilder
func isWrapped(_ flag: Bool) -> some View {
if flag { self }
else { self.content }
}
}

Because of the nature of SwiftUI, you can't remove the parent without removing children. As you mentioned in your edit, this will require two blocks of code. But, you can make it much more friendly in two different ways.
The first way is a reusable component. It takes a Binding isVisible variable and the content.
struct ConditionalScrollView<Content: View>: View {
#Binding private var isVisible: Bool
private var builtContent: Content
init(isVisible: Binding<Bool>, content: () -> Content) {
self._isVisible = isVisible
builtContent = content()
}
var body: some View {
if isVisible {
ScrollView { builtContent }
} else {
builtContent
}
}
}
To use this new component is to simply replace the use of ScrollView in the area that you want to manually adjust. I.E:
struct ContentView: View {
#State var isVisible = true
var body: some View {
ConditionalScrollView(isVisible: $isVisible) {
Text("Hello, world!")
.padding()
}
}
}
But this is not your only option.
If you want to keep things as simple as possible, you can achieve this using a simple ViewBuilder. Create the view you don't want to change inside the ViewBuilder, and then create a condition around the ScrollView, all the while placing the variable as the content. It would look as follows:
struct ContentView: View {
#State private var isVisible = true
#ViewBuilder private var mainContent: some View {
Text("Hello, world!")
.padding()
}
var body: some View {
if isVisible {
ScrollView { mainContent }
} else {
mainContent
}
}
}
As you've probably come to realize, this is one of the limitations of SwiftUI. But it can be considered a strength because you will always know if a view's parent is there or not.
I hope this little tidbit helped, happy coding!

You can also make it even easier:
#State var isWraped : bool = true
var body: some View {
if isWrapped {
ScrollView {
YourView()
}
else {
YourView()
}

Related

Problems with SwiftUI not redrawing views

Working on my first SwiftUI project, and as I started moving some of my more complex views into their own view structs I started getting problems with the views not being redrawn.
As an example, I have the following superview:
struct ContainerView: View {
#State var myDataObject: MyDataObject?
var body: some View {
if let myDataObject = myDataObject {
TheSmallerView(myDataObject: myDataObject)
.padding(.vertical, 10)
.frame(idealHeight: 10)
.padding(.horizontal, 8)
.onAppear {
findRandomData()
}
}
else {
Text("No random data found!")
.onAppear {
findRandomData()
}
}
}
private func findRandomData() {
myDataObject = DataManager.shared.randomData
}
}
Now when this first gets drawn I get the Text view on screen as the myDataObject var is nil, but the .onAppear from that gets called, and myDataStruct gets set with an actual struct. I've added breakpoints in the body variable, and I see that when this happens it gets called again and it goes into the first if clause and fetches the "TheSmallerView" view, but nothing gets redrawn on screen. It still shows the Text view from before.
What am I missing here?
EDIT: Here's the relevant parts of TheSmallerView:
struct TheSmallerView: View {
#ObservedObject var myDataObject: MyDataObject
EDIT2: Fixed the code to better reflect my actual code.
Try declaring #Binding var myDataStruct: MyDataStruct inside the TheSmallerView view and pass it like this: TheSmallerView(myDataStruct: $myDataStruct) from ContainerView
You are using #ObservedObject in the subview, but that property wrapper is only for classes (and your data is a struct).
You can use #State instead (b/c the data is a struct).
Edit:
The data isn't a struct.
Because it is a class, you should use #StateObject instead of #State.
In lack of complete code I created this simple example based on OPs code, which works fine the way it is expected to. So the problem seems to be somewhere else.
class MyDataObject: ObservableObject {
#Published var number: Int
init() {
number = Int.random(in: 0...1000)
}
}
struct ContentView: View {
#State var myDataObject: MyDataObject?
var body: some View {
if let myDataObject = myDataObject {
TheSmallerView(myDataObject: myDataObject)
.onAppear {
findRandomData()
}
}
else {
Text("No random data found!")
.onAppear {
findRandomData()
}
}
}
private func findRandomData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
myDataObject = MyDataObject()
}
}
}
struct TheSmallerView: View {
#ObservedObject var myDataObject: MyDataObject
var body: some View {
Text("The number is: \(myDataObject.number)")
}
}

Can't get removal of view to animate

I have a VStack where I have a title above some other views. The title is shown/hidden based on the value of an #Published variable from an environment object. Ideally, when the title should be hidden, I want it to transition by fading out and moving the views below it in the VStack up. However, what actually happens is it just disappears immediately and moves the rest of the views up without animation. How can I fix this?
struct MyView: View {
#EnvironmentObject var modelController: MyModelController
var body: some View {
VStack {
title()
//..other views here
}
}
#ViewBuilder
func title() -> some View {
if let currentPage = modelController.currentPage,
currentPage >= 6 {
EmptyView()
} else {
Text("Create Event")
}
}
}
You'll need to use the .transition() modifier to tell the system that you want to animate when the view appears or disappears. Additionally, I don't think you need to return EmptyView when you want to hide the title.
#ViewBuilder
func title() -> some View {
if modelController.currentPage ?? 0 < 6 {
Text("Create Event")
.transition(.opacity)
}
}
I've used the opacity transition but it's a very customizable modifier, and you can pass an asymmetric transition that does different animations on insert and removal. I would suggest googling it or looking at the documentation to learn more about it.
Your code snapshot is not testable, but try the following
VStack {
title()
//..other views here
}
.animation(.default, value: modelController.currentPage) // << here !!

SwiftUI send button tap to subview

I have a few views that contain the same button with some different content. Because of this I made a ContainerView that houses the shared Button layout, and has room for a generic ContentView.
I want the ContentView to respond when the ContainerView button is tapped.
Using UIKit, I would hold a reference to the ContentView in the ContainerView and call a function on it when the button was hit. However, because SwiftUI has all the views as structs, the contentView is copied when put into the ContainerView's body. Thus the reference and the shown ContentView are different & I cannot send the subview a message.
Code:
struct ContainerView: View {
let contentView = ContentView()
var body: some View {
Group {
/// When this button is tapped, I would like to send a message to the `ContentView`.
Button(action: self.reset, label: { Text("RESET") })
/// Unfortunately, this seemes to send a copy of the `contentView`. So I am unable to send the
/// corrent struct a message.
///
/// How can I send a subview a message from the superview?
self.contentView
}
}
func reset() {
self.contentView.reset()
}
}
struct ContentView: View {
#State private var count: Int = 0
var body: some View {
Group {
Text("Count: \(self.count)")
Button(action: self.increment, label: { Text("Increment") })
}
}
func increment() {
self.count += 1
}
/// When this is called from the `ContainerView`, it is accessing a different ContentView
/// struct than is being displayed.
func reset() {
self.count = 0
}
}
So the question is: how can I send a message to & run some code in the ContentView when a button in the ContainerView is tapped?
Instead of trying to store a reference to the subview, why not make a binding between them? In your example, this could be by binding to the count.
struct ContainerView: View {
#State private var count = 0
var body: some View {
// Your Button wrapping the ContentView
ContentView(count: $count)
}
func reset() {
self.count = 0
}
}
struct ContentView: View {
#Binding var count: Int
// ContentView's body
}
When the ContainerView resets the count, the binding will update the child.
EDIT: I see your comments about wanting ContentView to control the reset logic. What about trying to replicate some of the functionality of things like NavigationLink, where an isActive: bool is set, then reset, by the system on navigation?
In your case, you could try the following:
struct ContainerView: View {
#State private var shouldReset: Bool = false
var body: some View {
// Your Button wrapping the ContentView
ContentView(shouldReset: $shouldReset)
}
func reset() {
self.shouldReset = true
}
}
struct ContentView: View {
#Binding var shouldReset: Bool {
didSet {
if shouldReset {
// Call your reset logic here
}
shouldReset = false
}
}
// ContentView's body
}
Your ContentView will know the change, we'll see it as a separate "state", and then that state is reset once the action is complete.
It's probably not the ideal solution, but to me it seems to replicate a pattern shown by some first party SwiftUI components.

SwiftUI: attach an animation to a transition

According to the Apple documentation we should be able to attach an animation directly to a transition. For example:
.transition(AnyTransition.slide.animation(.linear))
documentation for the method:
extension AnyTransition {
/// Attach an animation to this transition.
public func animation(_ animation: Animation?) -> AnyTransition
}
says:
Summary
Attaches an animation to this transition.
But I can't manage to make it work. Take a look at this minimum viable example (you can copy-paste it and try yourself):
import SwiftUI
struct AnimationTest: View {
#State private var show = false
var body: some View {
VStack {
if show {
Color.green
.transition(AnyTransition.slide.animation(.linear))
} else {
Color.yellow
.transition(AnyTransition.slide.animation(.linear))
}
Button(action: {
self.show.toggle()
}, label: {
Text("CHANGE VIEW!")
})
}
}
}
struct AnimationTest_Previews: PreviewProvider {
static var previews: some View {
AnimationTest()
}
}
As you can see no animation happens at all. Any ideas? Thank you.
You need to wrap the boolean toggling within a withAnimation() closure:
withAnimation {
self.show.toggle()
}
Tested the transition working on Xcode 11.2.1 while on the simulator; the Canvas doesn't preview it.
Please note that animations/transitions applied directly to a view have an effect on that particular view and its children. Moreover, according to the docs:
func animation(Animation?) -> View
Applies the given animation to all animatable values within the view.
Since the animatable value, in this case the Bool toggle enabling the transition, is external to the Color views, it must be animated explicitly from where it's set in the button's action. Alternatively, one can effectively attach the transition directly to the target views, but apply the animation to their container, thus enabling interpolation of changes to show. So, this also achieves the desired result:
struct AnimationTest: View {
#State private var show = false
var body: some View {
VStack {
if show {
Color.green
.transition(.slide)
} else {
Color.yellow
.transition(.slide)
}
Button(action: {
self.show.toggle()
}, label: {
Text("CHANGE VIEW!")
})
}
.animation(.linear)
}
}

SwiftUI add subview dynamically but the animation doesn't work

I would like to create a view in SwiftUI that add a subview dynamically and with animation.
struct ContentView : View {
#State private var isButtonVisible = false
var body: some View {
VStack {
Toggle(isOn: $isButtonVisible.animation()) {
Text("add view button")
}
if isButtonVisible {
AnyView(DetailView())
.transition(.move(edge: .trailing))
.animation(Animation.linear(duration: 2))
}else{
AnyView(Text("test"))
}
}
}
}
The above code works fine with the animation . however when i move the view selection part into a function, the animation is not working anymore (since i want to add different views dynamically, therefore, I put the logic in a function.)
struct ContentView : View {
#State private var isButtonVisible = false
var body: some View {
VStack {
Toggle(isOn: $isButtonVisible.animation()) {
Text("add view button")
}
subView().transition(.move(edge: .trailing))
.animation(Animation.linear(duration: 2))
}
func subView() -> some View {
if isButtonVisible {
return AnyView(DetailView())
}else{
return AnyView(Text("test"))
}
}
}
it looks totally the same to me, however, i don't understand why they have different result. Could somebody explain me why? and any better solutions? thanks alot!
Here's your code, modified so that it works:
struct ContentView : View {
#State private var isButtonVisible = false
var body: some View {
VStack {
Toggle(isOn: $isButtonVisible.animation()) {
Text("add view button")
}
subView()
.transition(.move(edge: .trailing))
.animation(Animation.linear(duration: 2))
}
}
func subView() -> some View {
Group {
if isButtonVisible {
DetailView()
} else {
Text("test")
}
}
}
}
Note two things:
Your two examples above are different, which is why you get different results. The first applies a transition and animation to a DetailView, then type-erases it with AnyView. The second type-erases a DetailView with AnyView, then applies a transition and animation.
Rather that using AnyView and type-erasure, I prefer to encapsulate the conditional logic inside of a Group view. Then the type you return is Group, which will animate properly.
If you wanted different animations on the two possibilities for your subview, you can now apply them directly to DetailView() or Text("test").
Update
The Group method will only work with if, elseif, and else statements. If you want to use a switch, you will have to wrap each branch in AnyView(). However, this breaks transitions/animations. Using switch and setting custom animations is currently not possible.
I was able to get it to work with a switch statement by wrapping the function that returns an AnyView in a VStack. I also had to give the AnyView an .id so SwiftUI can know when it changes. This is on Xcode 11.3 and iOS 13.3
struct EnumView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
view(for: viewModel.viewState)
.id(viewModel.viewState)
.transition(.opacity)
}
}
func view(for viewState: ViewModel.ViewState) -> AnyView {
switch viewState {
case .loading:
return AnyView(LoadingStateView(viewModel: self.viewModel))
case .dataLoaded:
return AnyView(LoadedStateView(viewModel: self.viewModel))
case let .error(error):
return AnyView(ErrorView(error: error, viewModel: viewModel))
}
}
}
Also for my example in the ViewModel I need to wrap the viewState changes in a withAnimation block
withAnimation {
self.viewState = .loading
}
In iOS 14 they added the possibility to use if let and switch statements in function builders. Maybe it helps for your issues:
https://www.hackingwithswift.com/articles/221/whats-new-in-swiftui-for-ios-14 (at the article's bottom)

Resources