SwiftUI: Init ObservableObject based on Environment - ios

In this example, the blue rectangle should be initially visible on devices with .regular size class and hidden on devices with .compact size class.
I'm using an ObservableObject called Settings and the #Published variable isVisible to manage visibilty of the rectangle. My problem is that I don't know how I can init Settings with the correct horizontalSizeClass from my ContentView. Right now I am using .onAppear to change the value of isVisible but this triggers .onReceive. On compact devices this causes the rectangle to be visible and fading out when the view is presented instead of being invisible right away.
How can I init Settings based on Environment values like horizontalSizeClass so that isVisible is correct from the start?
struct ContentView: View {
#Environment(\.horizontalSizeClass) var horizontalSizeClass
#StateObject var settings = Settings()
#State var opacity: CGFloat = 1
var body: some View {
VStack {
Button("Toggle Visibility") {
settings.isVisible.toggle()
}
.onReceive(settings.$isVisible) { _ in
withAnimation(.linear(duration: 2.0)) {
opacity = settings.isVisible ? 1 : 0
}
}
Rectangle()
.frame(width: 100, height: 100)
.foregroundColor(.blue)
.opacity(opacity)
}
.onAppear {
settings.isVisible = horizontalSizeClass == .regular // too late
}
}
}
class Settings: ObservableObject {
#Published var isVisible: Bool = true // can't get size class here
}
The rectangle should not be visible on start:

We need just perform dependency injection (environment is known is parent so easily can be injected into child), and it is simple to do with internal view, like
struct ContentView: View {
#Environment(\.horizontalSizeClass) var horizontalSizeClass
struct MainView: View {
// later knonw injection
#EnvironmentObject var settings: Settings
var body: some View {
VStack {
Button("Toggle Visibility") {
settings.isVisible.toggle()
}
Rectangle()
.frame(width: 100, height: 100)
.foregroundColor(.blue)
.opacity(settings.isVisible ? 1 : 0) // << direct dependency !!
}
.animation(.linear(duration: 2.0), value: settings.isVisible) // << explicit animation
}
}
var body: some View {
MainView() // << internal view
.environmentObject(
Settings(isVisible: horizontalSizeClass == .regular) // << initial injecttion !!
)
}
}
Tested with Xcode 13.4 / iOS 15.5
Test code is here

withAnimation should be done on the Button action changing the state, e.g.
import SwiftUI
struct RectangleTestView: View {
#Environment(\.horizontalSizeClass) var horizontalSizeClass
#State var settings = Settings()
var body: some View {
VStack {
Button("Toggle Visibility") {
withAnimation(.linear(duration: 2.0)) {
settings.isVisible.toggle()
}
}
Rectangle()
.frame(width: 100, height: 100)
.foregroundColor(.blue)
.opacity(settings.opacity)
}
.onAppear {
settings.isVisible = horizontalSizeClass == .regular
}
}
}
struct Settings {
var isVisible: Bool = true
var opacity: CGFloat {
isVisible ? 1 : 0
}
}
FYI we don't really use onReceive anymore since they added onChange. Also its best to keep view data in structs not move it to expensive objects. "Views are very cheap, we encourage you to make them your primary encapsulation mechanism" Data Essentials in SwiftUI WWDC 2020 at 20:50.

Related

Frame transition animation works conditionally

I am trying to make child view which contains string array moves back and forth with animation as button on parent view is toggled.
However, child view just show up and disappeared without any animation at all.
struct ParentView: View {
#State isToggle: Bool
var body: some View {
ChildView(isToggle: isToggle)
.onTabGesture {
withAnimation {
isToggle.toggle()
}
}
}
}
struct ChildView: View {
let values = [one, two, three, four]
var isToggle: Bool
var body: some View {
HStack {
ForEach(values) { value in
Text("\(value)")
.frame(width: UIScreen.main.bounds.width / 3)
}
}
.frame(width: UIScreen.main.bounds.width, alignment: isToggle ? .trailing : .leading)
}
I changed code(stored property to viewmodel) as below. and It works as expected.
class ViewModel: ObservableObject {
#Published var values = [one, two, three, four]
}
struct ParentView: View {
#State isToggle: Bool
var body: some View {
ChildView(isToggle: isToggle)
.onTabGesture {
withAnimation {
isToggle.toggle()
}
}
}
}
struct ChildView: View {
#EnvironmentObject private vm: ViewModel
var isToggle: Bool
var body: some View {
HStack {
ForEach(values) { vm.value in
Text("\(value)")
.frame(width: UIScreen.main.bounds.width / 3)
}
}
.frame(width: UIScreen.main.bounds.width, alignment: isToggle ? .trailing : .leading)
}
I thought that toggling state redraws view only when with stored property.
But, child view with viewmodel is still redrawn when toggle state changes.
Data itself not changed at all. Please kindly let me know why this is happening.
there are some minor typos in your first code. If I correct them the code runs, and the animation works:
struct ContentView: View {
#State private var isToggle: Bool = false
var body: some View {
ChildView(isToggle: isToggle)
.onTapGesture {
withAnimation {
isToggle.toggle()
}
}
}
}
struct ChildView: View {
let values = ["one", "two", "three", "four"]
var isToggle: Bool
var body: some View {
HStack {
ForEach(values, id: \.self) { value in
Text("\(value)")
.frame(width: UIScreen.main.bounds.width / 3)
}
}
.frame(width: UIScreen.main.bounds.width, alignment: isToggle ? .trailing : .leading)
}
}

SwiftUI basic animation

Let's say I have a number and I want to animate the transition each time it changes its value. So the value disappears to the left and the new increased value appears from the right. Can this easily be made using .transition(.slide)?
struct ContentView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
VStack(spacing: 64) {
Text(viewModel.count.description)
.font(.largeTitle)
Button("Increment", action: viewModel.increment)
}
}
}
final class ViewModel: ObservableObject {
#Published var count = 0
func increment() {
count += 1
}
}
The .slide transition by default inserts from left and removes to right. We can use asymmetric move transition to achieve your goal.
Here is a raw demo of possible approach. Tested with Xcode 13 / iOS 15.
struct ContentView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
VStack(spacing: 64) {
// transition works on view insertion/remove that is
// why we conditionally switch views
if 1 == viewModel.count % 2 {
NumberView(number: viewModel.count)
} else {
NumberView(number: viewModel.count)
}
Button("Increment", action: viewModel.increment)
}
.animation(.default, value: viewModel.count)
}
}
// By default on transition view is removed to the edge from
// which it appears, that is why we need asymmetric transition
struct NumberView: View {
let number: Int
var body: some View {
Text("\(number)")
.font(.largeTitle)
.frame(maxWidth: .infinity)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
}

How to change the label of a button by using an if statement in SwiftUI

I want my button to look different after pressing the button. I am trying to do so by using a ZStack and an if statement. I don't understand why it won't toggle between a black button and a white button... P.S. I am getting experience using ObservableObject protocol.
class WelcomeButton: ObservableObject {
#Published var hasBeenPressed = false
}
struct WelcomeScreenButton: View {
#ObservedObject var welcomeButton = WelcomeButton()
var body: some View {
ZStack {
if welcomeButton.hasBeenPressed {
Circle()
.fill(Color(.white))
}
else {
Circle()
.fill(Color(.black))
}
}
}
}
struct ContentView: View {
#ObservedObject var welcomeButton = WelcomeButton()
var body: some View {
VStack {
Button(action: {welcomeButton.hasBeenPressed.toggle()})
{
WelcomeScreenButton()
}
.frame(width: 375, height: 375, alignment: .center)
}
}
}
You're very close, but you have to have the same instance of the WelcomeButton ObservableObject shared between the two. Right now, they're two separate instances, so when you update hasBeenPressed on one, the other one doesn't know to change its state.
struct WelcomeScreenButton: View {
#ObservedObject var welcomeButton : WelcomeButton //<-- here
var body: some View {
ZStack {
if welcomeButton.hasBeenPressed {
Circle()
.fill(Color(.white))
}
else {
Circle()
.fill(Color(.black))
}
}
}
}
struct ContentView: View {
#ObservedObject var welcomeButton = WelcomeButton()
var body: some View {
VStack {
Button(action: {welcomeButton.hasBeenPressed.toggle()})
{
WelcomeScreenButton(welcomeButton: welcomeButton) //<-- here
}
.frame(width: 375, height: 375, alignment: .center)
}
}
}
PS -- just as a general practice, it might be good to make the naming a little different just to keep things straight. WelcomeButton sounds like it's going to be a button. Naming it WelcomeButtonViewModel instead would make its intention more clear, although it wouldn't change the functionality at all.

How to Animate Binding of Child View

A bit confused as to why this code does not work as expected.
Some generic animatable view:
struct SomeAnimatableView: View, Animatable {
var percent: Double
var body: some View {
GeometryReader { geo in
ZStack {
Rectangle()
.fill(Color.blue)
.frame(height: 120)
Rectangle()
.fill(Color.red)
.frame(width: geo.size.width * CGFloat(self.percent), height: 116)
}
.frame(width: geo.size.width, height: geo.size.height)
}
}
var animatableData: Double {
set { percent = newValue }
get { percent }
}
}
A view which wraps said animatable view:
struct ProgressRectangle: View {
#Binding var percent: Double
var body: some View {
SomeAnimatableView(percent: percent)
}
}
Finally another view, consider it parent:
struct ContentView: View {
#State var percent: Double
var body: some View {
VStack {
ProgressRectangle(percent: $percent.animation())
Text("Percent: \(percent * 100)")
}
.onTapGesture {
self.percent = 1
}
}
}
Issue ⚠️ why doesn't the progress animate? $percent.animation() isn't that supposed cause animations? Lots of questions...
A fix 💡:
.onTapGesture {
withAnimation {
self.percent = 1
}
}
However, this doesn't actually fix the issue or answer the question to why that binding doesn't animate. Because this also seems to animate the entire view, notice the button animating its width in this example:
Thoughts, ideas?
Update: additional clarification
Animation to Binding is a mechanism to transfer animation inside child so when bound variable is changed internally it would be changed with animation.
Thus for considered scenario ProgressRectangle would look like
struct ProgressRectangle: View {
#Binding var percent: Double
var body: some View {
SomeAnimatableView(percent: percent)
.onTapGesture {
withAnimation(_percent.transaction.animation) { // << here !!
self.percent = 0 == self.percent ? 0.8 : 0
}
}
}
}
without onTapGesture in ContentView (similarly as would you have Toggle which changes value internally).
Test module on GitHub
Original (still valid and works)
Here is working solution. Tested with Xcode 11.4 / iOS 13.4
#State var percent: Double = .zero
var body: some View {
VStack {
ProgressRectangle(percent: $percent)
.animation(.default) // << place here !!
.onTapGesture {
self.percent = self.percent == .zero ? 1 : .zero
}
Text("Percent: \(percent * 100)")
}
}

SwiftUI drag gesture across multiple subviews

I'm attempting to create a grid of small square views, that when the user hovers over them with their thumb (or swipes across them), the little squares will temporarily "pop up" and shake. Then, if they continue to long press on that view, it would open up another view with more information.
I thought that implementing a drag gesture on the square views would be enough, but it looks like only one view can capture a drag gesture at a time.
Is there way to enable multiple views to capture a drag gesture, or a way to implement a "hover" gesture for iOS?
Here is my main Grid view:
import SwiftUI
struct ContentView: View {
#EnvironmentObject var data: PlayerData
var body: some View {
VStack {
HStack {
PlayerView(player: self.data.players[0])
PlayerView(player: self.data.players[1])
PlayerView(player: self.data.players[2])
}
HStack {
PlayerView(player: self.data.players[3])
PlayerView(player: self.data.players[4])
PlayerView(player: self.data.players[5])
}
HStack {
PlayerView(player: self.data.players[6])
PlayerView(player: self.data.players[7])
PlayerView(player: self.data.players[8])
}
HStack {
PlayerView(player: self.data.players[9])
PlayerView(player: self.data.players[10])
}
}
}
}
And here is my Square view that would hold a small summary to display on the square:
import SwiftUI
struct PlayerView: View {
#State var scaleFactor: CGFloat = 1.0
var player: Player = Player(name: "Phile", color: .green, age: 42)
var body: some View {
ZStack(alignment: .topLeading) {
Rectangle().frame(width: 100, height: 100).foregroundColor(player.color).cornerRadius(15.0).scaleEffect(self.scaleFactor)
VStack {
Text(player.name)
Text("Age: \(player.age)")
}.padding([.top, .leading], 10)
}.gesture(DragGesture().onChanged { _ in
self.scaleFactor = 1.5
}.onEnded {_ in
self.scaleFactor = 1.0
})
}
}
Here is a demo of possible approach... (it is simplified version of your app data settings, but the idea and direction where to evolve should be clear)
The main idea that you capture drag not in item view but in the content view transferring needed states (or calculable dependent data) into item view when (or if) needed.
struct PlayerView: View {
var scaled: Bool = false
var player: Player = Player(name: "Phile", color: .green, age: 42)
var body: some View {
ZStack(alignment: .topLeading) {
Rectangle().frame(width: 100, height: 100).foregroundColor(player.color).cornerRadius(15.0).scaleEffect(scaled ? 1.5 : 1)
VStack {
Text(player.name)
Text("Age: \(player.age)")
}.padding([.top, .leading], 10)
}.zIndex(scaled ? 2 : 1)
}
}
struct ContentView: View {
#EnvironmentObject var data: PlayerData
#GestureState private var location: CGPoint = .zero
#State private var highlighted: Int? = nil
private var Content: some View {
VStack {
HStack {
ForEach(0..<3) { i in
PlayerView(scaled: self.highlighted == i, player: self.data.players[i])
.background(self.rectReader(index: i))
}
}
.zIndex((0..<3).contains(highlighted ?? -1) ? 2 : 1)
HStack {
ForEach(3..<6) { i in
PlayerView(scaled: self.highlighted == i, player: self.data.players[i])
.background(self.rectReader(index: i))
}
}
.zIndex((3..<6).contains(highlighted ?? -1) ? 2 : 1)
}
}
func rectReader(index: Int) -> some View {
return GeometryReader { (geometry) -> AnyView in
if geometry.frame(in: .global).contains(self.location) {
DispatchQueue.main.async {
self.highlighted = index
}
}
return AnyView(Rectangle().fill(Color.clear))
}
}
var body: some View {
Content
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.updating($location) { (value, state, transaction) in
state = value.location
}.onEnded {_ in
self.highlighted = nil
})
}
}

Resources