SwiftUI: is possible Gesture var move into EnvironmentObject? - swiftui

I am not familiar with SWIFTUI and closure. Here move some gesture code to extension function, except var drag.
For Gesture, Is is possible to move the drag into environmentObject? How to initialize ?
class ViewState:ObservableObject {
#Published var isDragging = false
#Published var size: CGFloat = 100.0
}
struct ContentView: View {
#StateObject var viewState:ViewState=ViewState()
var drag:some Gesture { isDrag(viewState) }
var body: some View {
Circle()
.fill(viewState.isDragging ? Color.red: Color.blue)
.frame(width:viewState.size, height:viewState.size, alignment: .center)
.gesture(drag)
}
}
extension ContentView {
func isDrag(_ viewState:ViewState)->some Gesture {
DragGesture()
.onChanged {_ in
viewState.isDragging = true
viewState.size += 10.0
}
.onEnded{_ in
viewState.isDragging = false
viewState.size = 100.0
}
}
}

Done, Closure is function! Now, I can move this code to other views, but still update with view risk. and declared data (environmentObject) is not independent.
class ViewState:ObservableObject {
#Published var isDragging = false
#Published var size: CGFloat = 100.0
}
struct ContentView: View {
#StateObject var viewState:ViewState=ViewState()
// var drag:some Gesture { }
var body: some View {
Circle()
.fill(viewState.isDragging ? Color.red: Color.blue)
.frame(width:viewState.size, height:viewState.size, alignment: .center)
.isDragged(viewState)
}
}
extension View {
func drag(_ viewState:ViewState)->some Gesture {
DragGesture()
.onChanged {_ in
viewState.isDragging = true
viewState.size += 10.0
}
.onEnded{_ in
viewState.isDragging = false
viewState.size = 100.0
}
}
#ViewBuilder func isDragged(_ viewState:ViewState) -> some View {
self.gesture(drag(viewState))
}
}

Related

SwiftUI gesture state is reset between gestures

I have the following code for a simple square to which I attach a MagnificationGesture to update its state with a pinch to zoom gesture.
import SwiftUI
struct ContentView2: View {
var scale: CGFloat = 1
var magnificationGesture: some Gesture {
MagnificationGesture()
.onChanged { value in
scale = value
}
}
var body: some View {
VStack {
Text("\(scale)")
Spacer()
Rectangle()
.fill(Color.red)
.scaleEffect(self.scale)
.gesture(
magnificationGesture
)
Spacer()
}
}
}
struct ContentView2_Previews: PreviewProvider {
static var previews: some View {
ContentView2()
}
}
However this simple view behaves weird. When I perform the gesture, the scale #State property is succesfully modified. However when I then do another gesture with my hands, the scale property is reset to its initial state, instead of starting from its current value.
Here is a video of what happens. For example, when the red view is very small, performing the gesture, I would expect that it stays small, instead of completely resetting.
How can I get the desired behaviour - that is - the scale property is not reset
I was able to get it working by adding a bit to the code. Check it out and let me know if this works for your use case:
import SwiftUI
struct ContentView2: View {
var magGesture = MagnificationGesture()
#State var magScale: CGFloat = 1
#State var progressingScale: CGFloat = 1
var magnificationGesture: some Gesture {
magGesture
.onChanged { progressingScale = $0 }
.onEnded {
magScale *= $0
progressingScale = 1
}
}
var body: some View {
VStack {
Text("\(magScale)")
Spacer()
Rectangle()
.fill(Color.red)
.scaleEffect(self.magScale * progressingScale)
.gesture(
magnificationGesture
)
Spacer()
}
}
}
struct ContentView2_Previews: PreviewProvider {
static var previews: some View {
ContentView2()
}
}
I solved it by adding another scale and only updating one of them at the end to keep track of the scale
Code
import SwiftUI
struct ContentView2: View {
#State var previousScale: CGFloat = 1
#State var newScale: CGFloat = 1
var magnificationGesture: some Gesture {
MagnificationGesture()
.onChanged { value in
newScale = previousScale * value
}
.onEnded { value in
previousScale *= value
}
}
var body: some View {
VStack {
Text("\(newScale)")
Spacer()
Rectangle()
.fill(Color.red)
.scaleEffect(newScale)
.gesture(
magnificationGesture
)
Spacer()
}
}
}
struct ContentView2_Previews: PreviewProvider {
static var previews: some View {
ContentView2()
}
}

SwiftUI How to Make Page TabView Dynamic

So I am trying to make my TabView height dynamic. I have been looking for a way to do this but I can't seem to find a solution anywhere. This is how my code looks like.
struct ContentView: View {
#State var contentHeight: CGFloat = 0
var body: some View {
NavigationView {
ScrollView {
VStack {
TabView {
TestView1(contentHeight: $contentHeight)
TestView2(contentHeight: $contentHeight)
}
.tabViewStyle(.page)
.frame(height: contentHeight)
.indexViewStyle(.page(backgroundDisplayMode: .always))
.background(.yellow)
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Test Project")
}
}
}
}
This is how my test1view and test2view look like.
struct TestView1: View {
#State var height: CGFloat = 0
#Binding var contentHeight: CGFloat
var body: some View {
Color.red
.frame(maxWidth:.infinity, minHeight: 200, maxHeight: 200)
.background(
GeometryReader { geo in
Color.clear
.preference(
key: HeightPreferenceKey.self,
value: geo.size.height
)
.onAppear {
contentHeight = height
}
}
.onPreferenceChange(HeightPreferenceKey.self) { height in
self.height = height
}
)
}
}
struct TestView2: View {
#Binding var contentHeight: CGFloat
#State var height: CGFloat = 0
var body: some View {
Color.black
.frame(maxWidth:.infinity, minHeight: 350, maxHeight: 350)
.background(
GeometryReader { geo in
Color.clear
.preference(
key: HeightPreferenceKey.self,
value: geo.size.height
)
.onAppear {
contentHeight = height
}
}
.onPreferenceChange(HeightPreferenceKey.self) { height in
self.height = height
}
)
}
}
struct HeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
Now the problem is that when I drag it just a little the height changes. So when I drag it a little to the left the height changes to the height of TestView2 and it is still on TestView1.
I tried to add a drag gesture but it didn't let me swipe to the next page. So I don't know how I will be able to achieve this. Ive been looking for a solution but still no luck.
You can use the TabView($selection) initializer to do this. https://developer.apple.com/documentation/swiftui/tabview/init(selection:content:)
It tells you which tab you're currently viewing. Based off the middle point of the screen. And you don't have to deal with nasty GeometryReader and HeightPreferenceKey.
Here's your updated code. I even added a nice animation to fade between the two heights!
struct ContentView: View {
#State var selectedTab: Tab = .first
#State var animatedContentHeight: CGFloat = 300
enum Tab {
case first
case second
var contentHeight: CGFloat {
switch self {
case .first:
return 200
case .second:
return 350
}
}
}
var body: some View {
TabView(selection: $selectedTab) {
TestView1()
.tag(Tab.first)
TestView2()
.tag(Tab.second)
}
.tabViewStyle(.page)
// .frame(height: selectedTab.contentHeight) // Uncomment to see without animation
.frame(height: animatedContentHeight)
.indexViewStyle(.page(backgroundDisplayMode: .always))
.onChange(of: selectedTab) { newValue in
print("now selected:", newValue)
withAnimation { animatedContentHeight = selectedTab.contentHeight }
}
}
}
struct TestView1: View {
var body: some View {
Color.red
}
}
struct TestView2: View {
var body: some View {
Color.black
}
}

How to stop SwiftUI DragGesture from animating subviews

I'm building a custom modal and when I drag the modal, any subviews that have animation's attached, they animate while I'm dragging. How do I stop this from happening?
I thought about passing down an #EnvironmentObject with a isDragging flag, but it's not very scalable (and doesn't work well with custom ButtonStyles)
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
.showModal(isShowing: .constant(true))
}
}
extension View {
func showModal(isShowing: Binding<Bool>) -> some View {
ViewOverlay(isShowing: isShowing, presenting: { self })
}
}
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
if isShowing {
Container()
.background(Color.red)
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
bottomState = value.translation.height
}
.onEnded { _ in
if bottomState > 50 {
withAnimation {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
}
}
}
}
struct Container: View {
var body: some View {
// I want this to not animate when dragging the modal
Text("CONTAINER")
.frame(maxWidth: .infinity, maxHeight: 200)
.animation(.spring())
}
}
UPDATE:
extension View {
func animationsDisabled(_ disabled: Bool) -> some View {
transaction { (tx: inout Transaction) in
tx.animation = tx.animation
tx.disablesAnimations = disabled
}
}
}
Container()
.animationsDisabled(isDragging || bottomState > 0)
In real life the Container contains a button with an animation on its pressed state
struct MyButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.9 : 1)
.animation(.spring())
}
}
Added the animationsDisabled function to the child view which does in fact stop the children moving during the drag.
What it doesn't do is stop the animation when the being initially slide in or dismissed.
Is there a way to know when a view is essentially not moving / transitioning?
Theoretically SwiftUI should not translate animation in this case, however I'm not sure if this is a bug - I would not use animation in Container in that generic way. The more I use animations the more tend to join them directly to specific values.
Anyway... here is possible workaround - break animation visibility by injecting different hosting controller in a middle.
Tested with Xcode 12 / iOS 14
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
Color.clear
if isShowing {
HelperView {
Container()
.background(Color.red)
}
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
bottomState = value.translation.height
}
.onEnded { _ in
if bottomState > 50 {
withAnimation {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
Color.clear
}
}
}
}
struct HelperView<Content: View>: UIViewRepresentable {
let content: () -> Content
func makeUIView(context: Context) -> UIView {
let controller = UIHostingController(rootView: content())
return controller.view
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
In Container, declare a binding var so you can pass the bottomState to the Container View:
struct Container: View {
#Binding var bottomState: CGFloat
.
.
.
.
}
Dont forget to pass bottomState to your Container View wherever you use it:
Container(bottomState: $bottomState)
Now in your Container View, you just need to declare that you don't want an animation while bottomState is being changed:
Text("CONTAINER")
.frame(maxWidth: .infinity, maxHeight: 200)
.animation(nil, value: bottomState) // You Need To Add This
.animation(.spring())
In .animation(nil, value: bottomState), by nil you are asking SwiftUI for no animations, while value of bottomState is being changed.
This approach is tested using Xcode 12 GM, iOS 14.0.1.
You must use the modifiers of the Text in the order i put them. that means that this will work:
.animation(nil, value: bottomState)
.animation(.spring())
but this won't work:
.animation(.spring())
.animation(nil, value: bottomState)
I also made sure that adding .animation(nil, value: bottomState) will only disable animations when bottomState is being changed, and the animation .animation(.spring()) should always work if bottomState is not being changed.
So this is my updated answer. I don't think there is a pretty way to do it so now I am doing it with a custom Button.
import SwiftUI
struct ContentView: View {
#State var isShowing = false
var body: some View {
Text("Hello, world!")
.padding()
.onTapGesture(count: 1, perform: {
withAnimation(.spring()) {
self.isShowing.toggle()
}
})
.showModal(isShowing: self.$isShowing)
}
}
extension View {
func showModal(isShowing: Binding<Bool>) -> some View {
ViewOverlay(isShowing: isShowing, presenting: { self })
}
}
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
#State var isDragging = false
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
if isShowing {
Container()
.background(Color.red)
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
isDragging = true
bottomState = value.translation.height
}
.onEnded { _ in
isDragging = false
if bottomState > 50 {
withAnimation(.spring()) {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
}
}
}
}
struct Container: View {
var body: some View {
CustomButton(action: {}, label: {
Text("Pressme")
})
.frame(maxWidth: .infinity, maxHeight: 200)
}
}
struct CustomButton<Label >: View where Label: View {
#State var isPressed = false
var action: () -> ()
var label: () -> Label
var body: some View {
label()
.scaleEffect(self.isPressed ? 0.9 : 1.0)
.gesture(DragGesture(minimumDistance: 0).onChanged({_ in
withAnimation(.spring()) {
self.isPressed = true
}
}).onEnded({_ in
withAnimation(.spring()) {
self.isPressed = false
action()
}
}))
}
}
The problem is that you can't use implicit animations inside the container as they will be animated when it moves. So you need to explicitly set an animation using withAnimation also for the button pressed, which I now did with a custom Button and a DragGesture.
It is the difference between explicit and implicit animation.
Take a look at this video where this topic is explored in detail:
https://www.youtube.com/watch?v=3krC2c56ceQ&list=PLpGHT1n4-mAtTj9oywMWoBx0dCGd51_yG&index=11

How do you call a function from a subview in SwiftUI?

I have a child/subview which is a template for instances inside the parent view. This child view a is circle that can be moved around the screen by the user. The initial drag of this circle is supposed to call a function in the parent view, which appends a new instance of the child view. Basically, initially moving the circle from its starting position creates a new circle in that start position. Then when you initially move that new one, which was just created, another new circle is created at that starting position again, etc...
How do I call the addChild() function that's in ContentView from the Child view?
Thank you, I appreciate any help.
import SwiftUI
struct ContentView: View {
#State var childInstances: [Child] = [Child(stateBinding: .constant(.zero))]
#State var childInstanceData: [CGSize] = [.zero]
#State var childIndex = 0
func addChild() {
self.childInstanceData.append(.zero)
self.childInstances.append(Child(stateBinding: $childInstanceData[childIndex]))
self.childIndex += 1
}
var body: some View {
ZStack {
ForEach(childInstances.indices, id: \.self) { index in
self.childInstances[index]
}
ForEach(childInstanceData.indices, id: \.self) { index in
Text("y: \(self.childInstanceData[index].height) : x: \(self.childInstanceData[index].width)")
.offset(y: CGFloat((index * 20) - 300))
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import SwiftUI
struct Child: View {
#Binding var stateBinding: CGSize
#State var isInitalDrag = true
#State var isOnce = true
#State var currentPosition: CGSize = .zero
#State var newPosition: CGSize = .zero
var body: some View {
Circle()
.frame(width: 50, height: 50)
.foregroundColor(.blue)
.offset(self.currentPosition)
.gesture(
DragGesture()
.onChanged { value in
if self.isInitalDrag && self.isOnce {
// Call function in ContentView here... How do I do it?
ContentView().addChild()
self.isOnce = false
}
self.currentPosition = CGSize(
width: CGFloat(value.translation.width + self.newPosition.width),
height: CGFloat(value.translation.height + self.newPosition.height)
)
self.stateBinding = self.currentPosition
}
.onEnded { value in
self.newPosition = self.currentPosition
self.isOnce = true
self.isInitalDrag = false
}
)
}
}
struct Child_Previews: PreviewProvider {
static var previews: some View {
Child(stateBinding: .constant(.zero))
}
}
Here is what I would do, I would create a function in the second view and invoke it under any circumstance, say when a button in the second view is pressed then invoke this function. And I would pass the function's callback in the main view.
Here is a code to demonstrate what I mean.
import SwiftUI
struct StackOverflow23: View {
var body: some View {
VStack {
Text("First View")
Divider()
// Note I am presenting my second view here and calling its function ".onAdd"
SecondView()
// Whenever this function is invoked inside `SecondView` then it will run the code in between these brackets.
.onAdd {
print("Run any function you want here")
}
}
}
}
struct StackOverflow23_Previews: PreviewProvider {
static var previews: some View {
StackOverflow23()
}
}
struct SecondView: View {
// Define a variable and give it default value
var onAdd = {}
var body: some View {
Button(action: {
// This button will invoke the callback stored in the variable, this can be invoked really from any other function. For example, onDrag call self.onAdd (its really up to you when to call this).
self.onAdd()
}) {
Text("Add from a second view")
}
}
// Create a function with the same name to keep things clean which returns a view (Read note 1 as why it returns view)
// It takes one argument which is a callback that will come from the main view and it will pass it down to the SecondView
func onAdd(_ callback: #escaping () -> ()) -> some View {
SecondView(onAdd: callback)
}
}
NOTE 1: The reason our onAdd function returns a view is because remember that swiftui is based on only views and every modifier returns a view itself. For example when you have a Text("test") and then add .foregroundColor(Color.white) to it, what essentially you are doing is that you are not modifying the color of the text but instead you are creating a new Text with a custom foregroundColor value.
And this is exactly what we are doing, we are creating a variable that can be initialized when calling SecondView but instead of calling it in the initializer we created it as a function modifier that will return a new instance of SecondView with a custom value to our variable.
I hope this makes sense. If you have any questions don't hesitate to ask.
And here is your code modified:
ContentView.swift
import SwiftUI
struct ContentView: View {
#State var childInstances: [Child] = [Child(stateBinding: .constant(.zero))]
#State var childInstanceData: [CGSize] = [.zero]
#State var childIndex = 0
func addChild() {
self.childInstanceData.append(.zero)
self.childInstances.append(Child(stateBinding: $childInstanceData[childIndex]))
self.childIndex += 1
}
var body: some View {
ZStack {
ForEach(childInstances.indices, id: \.self) { index in
self.childInstances[index]
.onAddChild {
self.addChild()
}
}
ForEach(childInstanceData.indices, id: \.self) { index in
Text("y: \(self.childInstanceData[index].height) : x: \(self.childInstanceData[index].width)")
.offset(y: CGFloat((index * 20) - 300))
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Child.swift
import SwiftUI
struct Child: View {
#Binding var stateBinding: CGSize
#State var isInitalDrag = true
#State var isOnce = true
#State var currentPosition: CGSize = .zero
#State var newPosition: CGSize = .zero
var onAddChild = {} // <- The variable to hold our callback
var body: some View {
Circle()
.frame(width: 50, height: 50)
.foregroundColor(.blue)
.offset(self.currentPosition)
.gesture(
DragGesture()
.onChanged { value in
if self.isInitalDrag && self.isOnce {
// Call function in ContentView here... How do I do it?
self.onAddChild() <- // Here is your solution
self.isOnce = false
}
self.currentPosition = CGSize(
width: CGFloat(value.translation.width + self.newPosition.width),
height: CGFloat(value.translation.height + self.newPosition.height)
)
self.stateBinding = self.currentPosition
}
.onEnded { value in
self.newPosition = self.currentPosition
self.isOnce = true
self.isInitalDrag = false
}
)
}
// Our function which will initialize our variable to store the callback
func onAddChild(_ callaback: #escaping () -> ()) -> some View {
Child(stateBinding: self.$stateBinding, isInitalDrag: self.isInitalDrag, isOnce: self.isOnce, currentPosition: self.currentPosition, newPosition: self.newPosition, onAddChild: callaback)
}
}
struct Child_Previews: PreviewProvider {
static var previews: some View {
Child(stateBinding: .constant(.zero))
}
}
Here's your answer within my code:
import SwiftUI
struct ContentView: View {
#State var childInstances: [Child] = []
#State var childInstanceData: [CGSize] = []
#State var childIndex = 0
func addChild() {
self.childInstanceData.append(.zero)
self.childInstances.append(Child(stateBinding: $childInstanceData[childIndex]))
self.childIndex += 1
}
var body: some View {
ZStack {
ForEach(childInstances.indices , id: \.self) { index in
self.childInstances[index]
.onAddChild {
print(self.childInstances.count)
self.addChild()
}
}
VStack {
ForEach(childInstanceData.indices, id: \.self) { index in
Text("\(index). y: \(self.childInstanceData[index].height) : x: \(self.childInstanceData[index].width)")
}
}
.offset(y: -250)
}
.onAppear {
self.addChild()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import SwiftUI
struct Child: View {
#Binding var stateBinding: CGSize
var onAddChild = {} // <- The variable to hold our callback
#State var isInitalDrag = true
#State var isOnce = true
#State var currentPosition: CGSize = .zero
#State var newPosition: CGSize = .zero
var body: some View {
Circle()
.frame(width: 50, height: 50)
.foregroundColor(.blue)
.offset(self.currentPosition)
.gesture(
DragGesture()
.onChanged { value in
if self.isInitalDrag && self.isOnce {
// Call function in ContentView here:
self.onAddChild()
self.isOnce = false
}
self.currentPosition = CGSize(
width: CGFloat(value.translation.width + self.newPosition.width),
height: CGFloat(value.translation.height + self.newPosition.height)
)
self.stateBinding = self.currentPosition
}
.onEnded { value in
self.newPosition = self.currentPosition
self.isOnce = true
self.isInitalDrag = false
}
)
}
func onAddChild(_ callback: #escaping () -> ()) -> some View {
Child(stateBinding: self.$stateBinding, onAddChild: callback)
}
}
struct Child_Previews: PreviewProvider {
static var previews: some View {
Child(stateBinding: .constant(.zero))
}
}

Why won't my UI reflect a change in the #State property?

I'm trying to make a graph app, but animating it using a #State property does not help, for some reason.
struct GraphBars: View {
#State var percent: CGFloat
var body: some View {
Capsule()
.fill(Color.black)
.frame(width: 50, height: self.percent)
}
}
struct TEST3: View {
#State var bar1: CGFloat = 90.0
var body: some View {
GeometryReader { gg in
VStack {
Button(action: {
self.bar1 = 300.0
}) {
Text("Hello")
}
GraphBars(percent: bar1)
}
However, pressing the button does not increase the height of the bar as I thought it would. What am I doing wrong?
You need to use #Binding to transfer a variable back and forth between two Views otherwise the parent View doesn't get a notice of the variable change.
Do it like this:
struct GraphBars: View {
#Binding var percent: CGFloat
var body: some View {
Capsule()
.fill(Color.black)
.frame(width: 50, height: self.percent)
}
}
struct Test3: View {
#State var bar1: CGFloat = 90.0
var body: some View {
GeometryReader { gg in
VStack {
Button(action: {
self.bar1 = 300.0
}) {
Text("Hello")
}
GraphBars(percent: self.$bar1)
}
}
}
}