SwiftUI: Change #State variable through a function called externally? - swift

So maybe I'm misunderstanding how SwiftUI works, but I've been trying to do this for over an hour and still can't figure it out.
struct ContentView: View, AKMIDIListener {
#State var keyOn: Bool = false
var key: Rectangle = Rectangle()
var body: some View {
VStack() {
Text("Foo")
key
.fill(keyOn ? Color.red : Color.white)
.frame(width: 30, height: 60)
}
.frame(width: 400, height: 400, alignment: .center)
}
func receivedMIDINoteOn(noteNumber: MIDINoteNumber, velocity: MIDIVelocity, channel: MIDIChannel, portID: MIDIUniqueID? = nil, offset: MIDITimeStamp = 0) {
print("foo")
keyOn.toggle()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
So the idea is really simple. I have an external midi keyboard using AudioKit. When a key on the keyboard is pressed, the rectangle should change from white to red.
The receivedMIDINoteOn function is being called and 'foo' is printed to the console, and despite keyOn.toggle() appearing in the same function, this still won't work.
What's the proper way to do this?
Thanks

Yes, you are thinking of it slightly wrong. #State is typically for internal state changes. Have a button that your View directly references? Use #State. #Binding should be used when you don't (or shouldn't, at least) own the state. Typically, I use this when I have a parent view who should be influencing or be influenced by a subview.
But what you are likely looking for, is #ObservedObject. This allows an external object to publish changes and your View subscribes to those changes. So if you have some midi listening object, make it an ObservableObject.
final class MidiListener: ObservableObject, AKMIDIListener {
// 66 key keyboard, for example
#Published var pressedKeys: [Bool] = Array(repeating: false, count: 66)
init() {
// set up whatever private storage/delegation you need here
}
func receivedMIDINoteOn(noteNumber: MIDINoteNumber, velocity: MIDIVelocity, channel: MIDIChannel, portID: MIDIUniqueID? = nil, offset: MIDITimeStamp = 0) {
// how you determine which key(s) should be pressed is up to you. if this is monophonic the following will suffice while if it's poly, you'll need to do more work
DispatchQueue.main.async {
self.pressedKeys[Int(noteNumber)] = true
}
}
}
Now in your view:
struct KeyboardView: View {
#ObservedObject private var viewModel = MidiListener()
var body: some View {
HStack {
ForEach(0..<viewModel.pressedKeys.count) { index in
Rectangle().fill(viewModel.pressedKeys[index] ? Color.red : Color.white)
}
}
}
}
But what would be even better is to wrap your listening in a custom Combine.Publisher that posts these events. I will leave that as a separate question, how to do that.

Related

Why does my SwiftUI view not get onChange updates from a #Binding member of a #StateObject?

Given the setup I've outlined below, I'm trying to determine why ChildView's .onChange(of: _) is not receiving updates.
import SwiftUI
struct SomeItem: Equatable {
var doubleValue: Double
}
struct ParentView: View {
#State
private var someItem = SomeItem(doubleValue: 45)
var body: some View {
Color.black
.overlay(alignment: .top) {
Text(someItem.doubleValue.description)
.font(.system(size: 50))
.foregroundColor(.white)
}
.onTapGesture { someItem.doubleValue += 10.0 }
.overlay { ChildView(someItem: $someItem) }
}
}
struct ChildView: View {
#StateObject
var viewModel: ViewModel
init(someItem: Binding<SomeItem>) {
_viewModel = StateObject(wrappedValue: ViewModel(someItem: someItem))
}
var body: some View {
Rectangle()
.fill(Color.red)
.frame(width: 50, height: 70, alignment: .center)
.rotationEffect(
Angle(degrees: viewModel.someItem.doubleValue)
)
.onTapGesture { viewModel.changeItem() }
.onChange(of: viewModel.someItem) { _ in
print("Change Detected", viewModel.someItem.doubleValue)
}
}
}
#MainActor
final class ViewModel: ObservableObject {
#Binding
var someItem: SomeItem
public init(someItem: Binding<SomeItem>) {
self._someItem = someItem
}
public func changeItem() {
self.someItem = SomeItem(doubleValue: .zero)
}
}
Interestingly, if I make the following changes in ChildView, I get the behavior I want.
Change #StateObject to #ObservedObject
Change _viewModel = StateObject(wrappedValue: ViewModel(someItem: someItem)) to viewModel = ViewModel(someItem: someItem)
From what I understand, it is improper for ChildView's viewModel to be #ObservedObject because ChildView owns viewModel but #ObservedObject gives me the behavior I need whereas #StateObject does not.
Here are the differences I'm paying attention to:
When using #ObservedObject, I can tap the black area and see the changes applied to both the white text and red rectangle. I can also tap the red rectangle and see the changes observed in ParentView through the white text.
When using #StateObject, I can tap the black area and see the changes applied to both the white text and red rectangle. The problem lies in that I can tap the red rectangle here and see the changes reflected in ParentView but ChildView doesn't recognize the change (rotation does not change and "Change Detected" is not printed).
Is #ObservedObject actually correct since ViewModel contains a #Binding to a #State created in ParentView?
Normally, I would not write such a convoluted solution to a problem, but it sounds like from your comments on another answer there are certain architectural issues that you are required to conform to.
The general issue with your initial approach is that onChange is only going to run when the view has a render triggered. Generally, that happens because some a passed-in property has changed, #State has changed, or a publisher on an ObservableObject has changed. In this case, none of those are true -- you have a Binding on your ObservableObject, but nothing that triggers the view to re-render. If Bindings provided a publisher, it would be easy to hook into that value, but since they do not, it seems like the logical approach is to store the state in the parent view in a way in which we can watch a #Published value.
Again, this is not necessarily the route I would take, but hopefully it fits your requirements:
struct SomeItem: Equatable {
var doubleValue: Double
}
class Store : ObservableObject {
#Published var someItem = SomeItem(doubleValue: 45)
}
struct ParentView: View {
#StateObject private var store = Store()
var body: some View {
Color.black
.overlay(alignment: .top) {
Text(store.someItem.doubleValue.description)
.font(.system(size: 50))
.foregroundColor(.white)
}
.onTapGesture { store.someItem.doubleValue += 10.0 }
.overlay { ChildView(store: store) }
}
}
struct ChildView: View {
#StateObject private var viewModel: ViewModel
init(store: Store) {
_viewModel = StateObject(wrappedValue: ViewModel(store: store))
}
var body: some View {
Rectangle()
.fill(Color.red)
.frame(width: 50, height: 70, alignment: .center)
.rotationEffect(
Angle(degrees: viewModel.store.someItem.doubleValue)
)
.onTapGesture { viewModel.changeItem() }
.onChange(of: viewModel.store.someItem.doubleValue) { _ in
print("Change Detected", viewModel.store.someItem.doubleValue)
}
}
}
#MainActor
final class ViewModel: ObservableObject {
var store: Store
var cancellable : AnyCancellable?
public init(store: Store) {
self.store = store
cancellable = store.$someItem.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
public func changeItem() {
store.someItem = SomeItem(doubleValue: .zero)
}
}
Actually we don't use view model objects at all in SwiftUI, see [Data Essentials in SwiftUI WWDC 2020]. As shown in the video at 4:33 create a custom struct to hold the item, e.g. ChildViewConfig and init it in an #State in the parent. Set the childViewConfig.item in a handler or add any mutating custom funcs. Pass the binding $childViewConfig or $childViewConfig.item to the to the child View if you need write access. It's all very simple if you stick to structs and value semantics.

Text updating with ObservedObject but not my custom View

I am trying to code the board game Splendor as a simple coding project in SwiftUI. I'm a bit of a hack so please point me in the right direction if I've got this all wrong. I'm also attempting to use the MVVM paradigm.
The game board has two stacks of tokens on it, one for unclaimed game tokens on and one for the tokens of the active player. There is a button on the board which allows the player to claim a single token at a time.
The tokens are drawn in a custom view - TokenView() - which draws a simple Circle() offset by a small amount to make a stack. The number of circles matches the number of tokens. Underneath each stack of tokens is a Text() which prints the number of tokens.
When the button is pressed, only the Text() updates correctly, the number of tokens drawn remains constant.
I know my problem something to do with the fact that I'm mixing an #ObservedObject and a static Int. I can't work out how to not use the Int, as TokenView doesn't know whether it's drawing the board's token collection or the active players token collection. How do I pass the count of tokens as an #ObservedObject? And why does Text() update correctly?
Model:
struct TheGame {
var tokenCollection: TokenCollection
var players: [Player]
init() {
self.tokenCollection = TokenCollection()
self.players = [Player()]
}
}
struct Player {
var tokenCollection: TokenCollection
init() {
self.tokenCollection = TokenCollection()
}
}
struct TokenCollection {
var count: Int
init() {
self.count = 5
}
}
ViewModel:
class MyGame: ObservableObject {
#Published private (set) var theGame = TheGame()
func collectToken() {
theGame.tokenCollection.count -= 1
theGame.players[0].tokenCollection.count += 1
}
}
GameBoardView:
struct GameBoardView: View {
#StateObject var myGame = MyGame()
var body: some View {
VStack{
TokenStackView(myGame: myGame, tokenCount: myGame.theGame.tokenCollection.count)
.frame(width: 100, height: 200, alignment: .center)
Button {
myGame.collectToken()
} label: {
Text ("Collect Token")
}
TokenStackView(myGame: myGame, tokenCount: myGame.theGame.players[0].tokenCollection.count) .frame(width: 100, height: 200, alignment: .center)
}
}
}
TokenStackView:
struct TokenStackView: View {
#ObservedObject var myGame: MyGame
var tokenCount: Int
var body: some View {
VStack {
ZStack {
ForEach (0..<tokenCount) { index in
Circle()
.stroke(lineWidth: 5)
.offset(x: CGFloat(index * 10), y: CGFloat(index * 10))
}
}
Spacer()
Text("\(tokenCount)")
}
}
}
If you take a look at your console you'll see the error:
ForEach<Range<Int>, Int, OffsetShape<_StrokedShape<Circle>>> count (2) != its initial count (1). `ForEach(_:content:)` should only be used for *constant* data. Instead conform data to `Identifiable` or use `ForEach(_:id:content:)` and provide an explicit `id`!
The fix is pretty easy in this case, just add id, like this:
ForEach(0..<tokenCount, id: \.self) {

Why swift view does not update with UIKit?

I have swiftUI tabbar with animation also I have UITabbarViewController which contains this swiftUI view.
SwiftUI
struct MainTabBarView: View {
#ObservedObject var viewModel: MainTabBarViewModel
var body: some View {
VStack {
HStack(spacing: 0) {
TabItem(currentIndex: $viewModel.index, tabIndex: 0, tab: .home)
Spacer()
TabItem(currentIndex: $viewModel.index, tabIndex: 1, tab: .search)
Spacer()
TabItem(currentIndex: $viewModel.index, tabIndex: 2, tab: .library)
}.padding(.top, 8).padding(.leading, 28).padding(.trailing, 28)
.padding(.bottom, 8)
.frame(width: UIScreen.main.bounds.width)
.animation(.easeIn(duration: 0.2))
}
UITabbarViewController:
final class MainTabBarViewController: UITabBarController, Navigatable {
private var viewModel = MainTabBarViewModel()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
bindViewModel()
}
override func viewDidLoad() {
super.viewDidLoad()
configTabBarView()
}
private func configTabBarView() {
let view = UIHostingController(rootView: MainTabBarView(viewModel: viewModel))
addChild(child)
tabBar.addSubview(view.view)
view.didMove(toParent: self)
tabBar.setValue(true, forKey: "hidesShadow")
view.view.snp.makeConstraints { make in
make.leading.trailing.top.equalToSuperview()
make.height.equalTo(100)
}
}
ViewModel:
final class MainTabBarViewModel: NSObject, ObservableObject {
#Published var index: Int = 0
}
This is the code of my custom view with tabs.
When I press on tab -> index from viewModel is changing in this view and then
struct TabItem: View {
#Binding var currentIndex: Int
var tabIndex: Int
var isCurrentTab: Bool {
return currentIndex == tabIndex
}
var tab: Tabs
var body: some View {
HStack {
Image(uiImage: tab.icon).resizable().frame(width: 25, height: 25, alignment: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/)
.foregroundColor(isCurrentTab ? R.color.primaryColor()!.swiftUI : R.color.grayScaleDark()!.swiftUI)
Text(isCurrentTab ? tab.title : "").font(Font(R.font.poppinsRegular(size: 16)! as CTFont))
.foregroundColor(isCurrentTab ? R.color.primaryColor()!.swiftUI : R.color.grayScaleDark()!.swiftUI)
}.padding(15)
.onTapGesture {
self.currentIndex = tabIndex
logWarn(String(tabIndex))
}
.background(self.tabIndex == currentIndex ? Color(R.color.pink()!) : Color.white)
.frame(height: 44.0)
.cornerRadius(22.0)
.clipped()
}
}
The problems that index is changing, but tab does not update view. It is look, like view does not update.
Although it would be helpful for the given code to compile, I think I've pieced together a solution that allows the current index to remain in MainTabBarViewModel, under the assumption that the it is intended to hold other things in the future. I also make the assumption that TabItem is supposed to be sufficiently generic that it doesn't have to depend on MainTabVarViewModel specifically.
My idea is based on providing a key path to the current index when creating the TabItem. Were it not for the fact that TabItem updates the current index that would be simple enough. However, because it does update it, in a onTapGesture closure, the compiler complains that it can't write through the KeyPath, because self is immutable. So... summoning the ghost a David Wheeler, I tried doing it through a closure saved in TabItem.init... which doesn't exist, so adding that is part of the solution.
First TabItem becomes a generic:
struct TabItem<TabBarViewModel: ObservableObject>: View {
#ObservedObject var viewModel: TabBarViewModel
let indexKeyPath: WritableKeyPath<TabBarViewModel, Int>
let tapClosure: (Int) -> Void
// #Binding var currentIndex: Int
var currentIndex: Int { viewModel[keyPath: indexKeyPath] }
...
init(currentIndexIn viewModel: TabBarViewModel, at indexKeyPath: WritableKeyPath<TabBarViewModel, Int>, tabIndex: Int, tab: Tabs)
{
self.viewModel = viewModel
self.indexKeyPath = indexKeyPath
self.tabIndex = tabIndex
self.tab = tab
self.tapClosure = { self.viewModel[keyPath: indexKeyPath] = $0 }
}
With that everything in TabItem compiles (after removing references to unprovided code) except for this bit in body
.onTapGesture {
currentIndex = tabIndex
print(String(tabIndex))
}
I change that to use the closure that was saved off in the init:
.onTapGesture {
tapClosure(tabIndex)
print(String(tabIndex))
}
Then in MainTabBarView's body creating a TabItem changes from this:
TabItem(currentIndex: $viewModel.index, tabIndex: 0, tab: .home)
to this
TabItem(currentIndexIn: viewModel, at: \.index, tabIndex: 0, tab: .home)
Since there is too much code missing for me to compile and test it, this is a bit of a guess. I stubbed out missing code just to silence the errors for it, so I can say that at least this solution compiles. It would need to be applied and tested in the actual app to verify whether it works.
Anyway, assuming it does work, this solution still allows keeping the index in an observable object without relying on the particular type of that object.

SwiftUI: Stop an Animation that Repeats Forever

I would like to have a 'badge' of sorts on the screen and when conditions are met, it will bounce from normal size to bigger and back to normal repeatedly until the conditions are no longer met. I cannot seem to get the badge to stop 'bouncing', though. Once it starts, it's unstoppable.
What I've tried:
I have tried using a few animations, but they can be classified as animations that use 'repeatForever' to achieve the desired effect and those that do not. For example:
Animation.default.repeatForever(autoreverses: true)
and
Animation.spring(response: 1, dampingFraction: 0, blendDuration: 1)(Setting damping to 0 makes it go forever)
followed by swapping it out with .animation(nil). Doesn't seem to work. Does anyone have any ideas? Thank you so very much ahead of time! Here is the code to reproduce it:
struct theProblem: View {
#State var active: Bool = false
var body: some View {
Circle()
.scaleEffect( active ? 1.08: 1)
.animation( active ? Animation.default.repeatForever(autoreverses: true): nil )
.frame(width: 100, height: 100)
.onTapGesture {
self.active = !self.active
}
}
}
I figured it out!
An animation using .repeatForever() will not stop if you replace the animation with nil. It WILL stop if you replace it with the same animation but without .repeatForever(). ( Or alternatively with any other animation that comes to a stop, so you could use a linear animation with a duration of 0 to get a IMMEDIATE stop)
In other words, this will NOT work: .animation(active ? Animation.default.repeatForever() : nil)
But this DOES work: .animation(active ? Animation.default.repeatForever() : Animation.default)
In order to make this more readable and easy to use, I put it into an extension that you can use like this: .animation(Animation.default.repeat(while: active))
Here is an interactive example using my extension you can use with live previews to test it out:
import SwiftUI
extension Animation {
func `repeat`(while expression: Bool, autoreverses: Bool = true) -> Animation {
if expression {
return self.repeatForever(autoreverses: autoreverses)
} else {
return self
}
}
}
struct TheSolution: View {
#State var active: Bool = false
var body: some View {
Circle()
.scaleEffect( active ? 1.08: 1)
.animation(Animation.default.repeat(while: active))
.frame(width: 100, height: 100)
.onTapGesture {
self.active.toggle()
}
}
}
struct TheSolution_Previews: PreviewProvider {
static var previews: some View {
TheSolution()
}
}
As far as I have been able to tell, once you assign the animation, it will not ever go away until your View comes to a complete stop. So if you have a .default animation that is set to repeat forever and auto reverse and then you assign a linear animation with a duration of 4, you will notice that the default repeating animation is still going, but it's movements are getting slower until it stops completely at the end of our 4 seconds. So we are animating our default animation to a stop through a linear animation.
How about using a Transaction
In the code below, I turn off or turn on the animation depending on the state of the active
Warning: Be sure to use withAnimation otherwise nothing will work
#State var active: Bool = false
var body: some View {
Circle()
.scaleEffect(active ? 1.08: 1)
.animation(Animation.default.repeatForever(autoreverses: true), value: active)
.frame(width: 100, height: 100)
.onTapGesture {
useTransaction()
}
}
func useTransaction() {
var transaction = Transaction()
transaction.disablesAnimations = active ? true : false
withTransaction(transaction) {
withAnimation {
active.toggle()
}
}
}
After going through many things, I found out something that works for me. At the least for the time being and till I have time to figure out a better way.
struct WiggleAnimation<Content: View>: View {
var content: Content
#Binding var animate: Bool
#State private var wave = true
var body: some View {
ZStack {
content
if animate {
Image(systemName: "minus.circle.fill")
.foregroundColor(Color(.systemGray))
.offset(x: -25, y: -25)
}
}
.id(animate) //THIS IS THE MAGIC
.onChange(of: animate) { newValue in
if newValue {
let baseAnimation = Animation.linear(duration: 0.15)
withAnimation(baseAnimation.repeatForever(autoreverses: true)) {
wave.toggle()
}
}
}
.rotationEffect(.degrees(animate ? (wave ? 2.5 : -2.5) : 0.0),
anchor: .center)
}
init(animate: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content) {
self.content = content()
self._animate = animate
}
}
Use
#State private var editMode = false
WiggleAnimation(animate: $editMode) {
VStack {
Image(systemName: image)
.resizable()
.frame(width: UIScreen.screenWidth * 0.1,
height: UIScreen.screenWidth * 0.1)
.padding()
.foregroundColor(.white)
.background(.gray)
Text(text)
.multilineTextAlignment(.center)
.font(KMFont.tiny)
.foregroundColor(.black)
}
}
How does it work?
.id(animate) modifier here does not refresh the view but just replaces it with a new one, so it is back to its original state.
Again this might not be the best solution but it works for my case.
There is nothing wrong in your code, so I assume it is Apple's defect. It seems there are many with implicit animations (at least with Xcode 11.2). Anyway...
I recommend to consider alternate approach provided below that gives expected behaviour.
struct TestAnimationDeactivate: View {
#State var active: Bool = false
var body: some View {
VStack {
if active {
BlinkBadge()
} else {
Badge()
}
}
.frame(width: 100, height: 100)
.onTapGesture {
self.active.toggle()
}
}
}
struct Badge: View {
var body: some View {
Circle()
}
}
struct BlinkBadge: View {
#State private var animating = false
var body: some View {
Circle()
.scaleEffect(animating ? 1.08: 1)
.animation(Animation.default.repeatForever(autoreverses: true))
.onAppear {
self.animating = true
}
}
}
struct TestAnimationDeactivate_Previews: PreviewProvider {
static var previews: some View {
TestAnimationDeactivate()
}
}
Aspid comments on the accepted solution that an Xcode update broke it. I was struggling with a similar problem while playing around with an example from Hacking with Swift, and
.animation(active ? Animation.default.repeatForever() : Animation.default)
was not working for me either on Xcode 13.2.1. The solution I found was to encapsulate the animation in a custom ViewModifier. The code below illustrates this; the big button toggles between active and inactive animations.
`
struct ContentView: View {
#State private var animationAmount = 1.0
#State private var animationEnabled = false
var body: some View {
VStack {
Button("Tap Me") {
// We would like to stop the animation
animationEnabled.toggle()
animationAmount = animationEnabled ? 2 : 1
}
.onAppear {
animationAmount = 2
animationEnabled = true
}
.padding(50)
.background(.red)
.foregroundColor(.white)
.clipShape(Circle())
.overlay(
Circle()
.stroke(.red)
.scaleEffect(animationAmount)
.opacity(2 - animationAmount)
)
.modifier(AnimatedCircle(animationAmount: $animationAmount, animationEnabled: $animationEnabled))
}
}
}
struct AnimatedCircle: ViewModifier {
#Binding var animationAmount: Double
#Binding var animationEnabled: Bool
func body(content: Content) -> some View {
if animationEnabled {
return content.animation(.easeInOut(duration: 2).repeatForever(autoreverses: false),value: animationAmount)
}
else {
return content.animation(.easeInOut(duration: 0),value: animationAmount)
}
}
}
`
It may not be the best conceivable solution, but it works. I hope it helps somebody.

How can I know if a SwiftUI Button is enabled/disabled?

There is no isEnabled property for a SwiftUI button. How can i tell if it is enabled?
In regular UIKit, i would simply do
if button.isEnabeld == true {
} else {
}
but there is no SwiftUI equivalent.
Inside a view, if you wish to react to the state set by .disabled(true), you can use:
#Environment(\.isEnabled) var isEnabled
Since the environment can be used from within a View or a ViewModifier, this can be used to change layout properties of a view based on the state set from outside.
Unfortunately, ButtonStyle cannot directly use #Environment, but you can use a ViewModifier to inject environment values into a ButtonStyle in order to use the value from within a ButtonStyle:
// First create a button style that gets the isEnabled value injected
struct MyButtonStyle: ButtonStyle {
private let isEnabled: Bool
init(isEnabled: Bool = true) {
self.isEnabled = isEnabled
}
func makeBody(configuration: Configuration) -> some View {
return configuration
.label
.background(isEnabled ? .green : .gray)
.foregroundColor(isEnabled ? .black : .white)
}
}
// Then make a ViewModifier to inject the state
struct MyButtonModifier: ViewModifier {
#Environment(\.isEnabled) var isEnabled
func body(content: Content) -> some View {
return content.buttonStyle(MyButtonStyle(isEnabled: isEnabled))
}
}
// Then create a convenience function to apply the modifier
extension Button {
func styled() -> some View {
ModifiedContent(content: self, modifier: MyButtonModifier())
}
}
// Finally, try out the button and watch it respond to it's state
struct ContentView: View {
var body: some View {
Button("Test", {}).styled().disabled(true)
}
}
You can use this method to inject other things into a ButtonStyle, like size category and theme.
I use it with a custom style enum that contains all the flavours of button styles found in our design system.
From outside a view you should know if you used .disabled(true) modifier.
From inside a view you can use #Environment(\.isEnabled) to get that information:
struct MyButton: View {
let action: () -> Void
#Environment(\.isEnabled) private var isEnabled
var body: some View {
Button(action: action) {
Text("Click")
}
.foregroundColor(isEnabled ? .green : .gray)
}
}
struct MyButton_Previews: PreviewProvider {
static var previews: some View {
VStack {
MyButton(action: {})
MyButton(action: {}).disabled(true)
}
}
}
The whole idea of SwiftUI, is to avoid duplication of the source of truth. You need to think differently, and consider where the source of truth is. This is where you need to go to find out the button's state. Not from the button itself.
In "Data Flow Through SwiftUI", at minute 30:50, they explain that every piece of data has a single source of truth. If your button gets its state from some #Binding, #State, #EnvironmentObject, etc, your if statement should get that information from the same place too, not from the button.
Short answer: Just use inside struct:
#Environment(\.isEnabled) private var isEnabled
Button style with:
animation on hover change
animation on disable/enable change
can be applied on any button in native way of swiftUI
you need manually set size of buttons outside of the button
usage:
#State var isDisabled = false
///.......
Button("Styled button") { isDisabled.toggle() }
.buttonStyle(ButtStyle.BigButton()) // magic inside
.frame(width: 200, height: 50)
.disabled(isDisabled)
Button("switch isDisabled") { isDisabled.toggle() }
source code:
public struct ButtStyle { }
// Added style to easy stylyng in native way for SwiftUI
#available(macOS 11.0, *)
public extension ButtStyle {
struct BigButton: ButtonStyle {
init() {
}
public func makeBody(configuration: Configuration) -> some View {
BigButtonStyleView(configuration: configuration)
}
}
}
#available(macOS 11.0, *)
struct BigButtonStyleView : View {
let configuration: ButtonStyle.Configuration
#Environment(\.isEnabled) var isEnabled // here we getting "disabled"
#State var hover : Bool = false
var body: some View {
// added animations
MainFrameMod()
.animation(.easeInOut(duration: 0.2), value: hover)
.animation(.easeInOut(duration: 0.2), value: isEnabled)
}
// added opacity on move hover change
// and disabled status
#ViewBuilder
func MainFrameMod() -> some View {
if isEnabled {
MainFrame()
.opacity(hover ? 1 : 0.8)
.onHover{ hover = $0 }
} else {
MainFrame()
.opacity(0.5)
}
}
// Main interface of button
func MainFrame() -> some View {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(Color(hex: 0xD8D8D8))
configuration.label
.foregroundColor(.black)
.font(.custom("SF Pro", size: 18))
}
}
}
As mentioned by other developers, the main idea of SwiftUI is that the UI remains synced with the data. You can perform this in many different ways. This includes #State, #EnvironmentObject, #Binding etc.
struct ContentView: View {
#State private var isEnabled: Bool = false
var body: some View {
VStack {
Button("Press me!") {
}.disabled(isEnabled)
}
.padding()
}
}