How to change state of SwiftUI Toggle externally - swiftui

I am trying to change the state of a Toggle from outside the view. I've tried ObservableObject and EnvironmentObject, but the toggle is expecting a Binding (#State).
I need to execute a callback when the user taps the toggle
I need to change the state of the toggle programmatically w/o executing the callback.
I am using a shared model for this and other views, ideally I'd like to be able to use that for an 'enabled' Bool to take the place of the State var isOn.
This code does let me execute the callback via the extension, but I cannot figure out how to change the State variable isOn externally, and if I was able to, I'm guessing my callback would be executed, which I don't want to happen.
import SwiftUI
struct ControlView: View {
var title: String
var panel: Int
var callback: ()-> Void
#State public var isOn = false // toggle state
#EnvironmentObject var state: MainViewModel
//#ViewBuilder
var body: some View {
VStack() {
// -- Header
HStack() {
Text(" ")
Image(self.state.panelIcon(panel: panel)).resizable().frame(width: 13.0, height: 13.0)
Text(title)
Spacer()
}.padding(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
.background(Color(red: 0.9, green: 0.9, blue: 0.9))
// -- Switch
Toggle(isOn: $isOn.didSet { (state) in
// Activate ARC
callback()
}) {
Text("Enable ARC")
}.padding(EdgeInsets(top: 0, leading: 12, bottom: 10, trailing: 12))
}.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color(red: 0.8, green: 0.8, blue: 0.8), lineWidth: 1.25)
).background(Color.white)
}
}
extension Binding {
func didSet(execute: #escaping (Value) -> Void) -> Binding {
return Binding(
get: { self.wrappedValue },
set: {
self.wrappedValue = $0
execute($0)
}
)
}
}

You're on the right track by creating a custom Binding with a set function that performs your side effect. But instead of using a State, create a custom Binding that directly modifies the enabled property of your ObservableObject. Example:
import PlaygroundSupport
import SwiftUI
class MyModel: ObservableObject {
#Published var enabled: Bool = false
#Published var sideEffectCount: Int = 0
}
struct RootView: View {
#EnvironmentObject var model: MyModel
var body: some View {
List {
Text("Side effect count: \(model.sideEffectCount)")
Button("Set to false programmatically") {
model.enabled = false
}
Button("Set to true programmatically") {
model.enabled = true
}
Toggle("Toggle without side effect", isOn: $model.enabled)
Toggle("Toggle WITH side effect", isOn: Binding(
get: { model.enabled },
set: { newValue in
withAnimation {
if newValue {
model.sideEffectCount += 1
}
model.enabled = newValue
}
}
))
}
}
}
PlaygroundPage.current.setLiveView(
RootView()
.environmentObject(MyModel())
)

You can use onChange to trigger a side effect as the result of a value changing, such as a Binding. e.g.
.onChange(of:isOn) { [isOn] newValue in
if newValue {
model.sideEffectCount += 1
}
model.enabled = newValue
}

Related

SwiftUI not updating view based on loader

My view is like this
var body: some View {
ScrollView(.vertical) {
VStack(spacing: 10) {
ForEach(0 ..< loader.numberOfRows, id: \.self) { index in
Image("")
.frame(width: 100, height: 100)
.background(Color.random)
.clipped()
}
}
}
}
My loader is like this
final class GiphyLoader {
#Published var data: [GifData] = []
#Published var numberOfRows = 0
init() {
loadImages()
}
func loadImages() {
ImageService.getImages { [weak self] response in
self?.data = response?.data ?? []
guard let count = response?.data.count else {
return
}
self?.numberOfRows = count
print(self?.numberOfRows)
}
}
}
The print gives a count of 25 and nothing is displayed on the screen.
But when I change func loadImages to hard code 25 images then it displays 25 like this
func loadImages() {
numberOfRows = 25
}
How can I dynamically show my views based on what I get from the server?
Try the following:
struct ContentView: View {
#StateObject var loader = GiphyLoader()
var body: some View {
ScrollView(.vertical) {
VStack(spacing: 10) {
ForEach(0 ..< loader.numberOfRows, id: \.self) { index in
Image("")
.frame(width: 100, height: 100)
.background(Color.random)
.clipped()
}
}
}
}
}
If you haven't done so already, be sure to add an extension for Color to get a random color. Something like this will do:
extension Color {
static var random: Color {
return Color(
red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1)
)
}
}
And make your loader conform to ObservableObject like below.
final class GiphyLoader: ObservableObject {
#Published var data: [GifData] = []
#Published var numberOfRows = 0
init() {
loadImages()
}
func loadImages() {
ImageService.getImages { [weak self] response in
self?.data = response?.data ?? []
guard let count = response?.data.count else {
return
}
self?.numberOfRows = count
print(self?.numberOfRows)
}
}
}
Then make sure that any subsequent views use the #ObservedObject property wrapper instead of #StateObject, for example: #ObservedObject var viewModel: GiphyLoader.
I can't run it on my end to make sure it works, because I don't have the ImageService function or the GifData array, but this should solve your problems or at least get you going.
Be sure to understand the different property wrappers and their functions.
This Hacking with Swift page should lead you further down the rabbit hole

SwiftUI NavigationLink Bug (SwiftUI encountered an issue when pushing aNavigationLink. Please file a bug.)

Console Bug: SwiftUI encountered an issue when pushing aNavigationLink. Please file a bug.
There is no problem when I don't use the isActive parameter in NavigationLink. However, I have to use the isActive parameter. Because I'm closing the drop-down list accordingly.
Menu Model:
struct Menu: Identifiable {
var id: Int
var pageName: String
var icon: String
var page: Any
var startDelay: Double
var endDelay: Double
// var offsetY: CGFloat
}
let menu = [
Menu(id: 1, pageName: "Profil", icon: "person.crop.circle", page: ProfileView(), startDelay: 0.2, endDelay: 0.6),
Menu(id: 2, pageName: "Sepet", icon: "cart", page: CartView(), startDelay: 0.4, endDelay: 0.4),
Menu(id: 3, pageName: "İstek", icon: "plus.circle", page: ClaimView(), startDelay: 0.6, endDelay: 0.2)
]
MenuView
struct MenuView: View {
#State var isShownMenu: Bool = false
#State var isPresented: Bool = false
var body: some View {
VStack(spacing: 40) {
Button(action: {self.isShownMenu.toggle()}) {
MenuViewButton(page: .constant((Any).self), icon: .constant("rectangle.stack"))
}
VStack(spacing: 40) {
ForEach(menu, id: \.id) { item in
NavigationLink(
destination: AnyView(_fromValue: item.page),
isActive: self.$isPresented,
label: {
MenuViewButton(page: .constant(item.page), icon: .constant(item.icon))
.animation(Animation.easeInOut.delay(self.isShownMenu ? item.startDelay : item.endDelay))
.offset(x: self.isShownMenu ? .zero : UIScreen.main.bounds.width)//, y: item.offsetY)
}
}
.onChange(of: isPresented, perform: { value in
if value == true {
self.isShownMenu = false
}
})
}
}
}
The problem is that you have NavigationLink with the "IsActive" parameter placed in the ForEach cycle!
You need to remove NavigationLink from the cycle and transfer the necessary data there, for example, through the view model.
Summary: you should only have one NavigationLink associated with one specific isActive parameter.
ForEach(yourData) { dataItem in
Button {
selectedItem = dataItem
isActivated = true
} label: {
Text("\(dataItem)")
}
}
.background(
NavigationLink(destination: DestinationView(data: selectedItem),
isActive: $isActivated) {EmptyView()}
)
ForEach(items) { item in
NavigationLink(tag: item.id, selection: $selection) {
DetailView(selection: $selection, item: item)
} label: {
Text("\(item)")
}
}
I got this when I accidentally had two NavigationLinks with the same isActive boolean.

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 I change button backgroundcolor if the button is disabled in swiftUI

I'm trying to create a buttonStyle that has a different background color if the button is disabled.
How do I do that?
I've created the code below to react to a variable that I've introduced myself, but is it possible to have it react on the buttons .disabled() state?
My code:
struct MyButtonStyle: ButtonStyle {
var enabledState = false
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.foregroundColor(Color.white)
.padding(10)
.padding(.horizontal, 20)
.background(self.enabledState ? Color(UIColor.orange) : Color(UIColor.lightGray))
.cornerRadius(20)
.frame(minWidth: 112, idealWidth: 112, maxWidth: .infinity, minHeight: 40, idealHeight: 40, maxHeight: 40, alignment: .center)
.scaleEffect(configuration.isPressed ? 0.9 : 1.0)
}
}
struct ContentView: View {
#State private var buttonEnabled = false
var body: some View {
HStack {
Button("Button") {
self.buttonEnabled.toggle()
print("Button pressed")
}
}
.buttonStyle(MyButtonStyle(enabledState: self.buttonEnabled))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
To track .disabled there is EnvironmentValues.isEnabled that shows this state. But environment values are applicable only to views and do not work in style.
So the solution is to create custom button that tracks isEnabled and pass it into own style.
Below is a demo of solution approach (MyButtonStyle is not changed). Tested with Xcode 12b.
struct MyButton: View {
let title: String
let action: () -> ()
#Environment(\.isEnabled) var isEnabled // to handle own state !!
init(_ title: String, action: #escaping () -> ()) {
self.title = title
self.action = action
}
var body: some View {
Button(title, action: action)
.buttonStyle(MyButtonStyle(enabledState: isEnabled))
}
}
struct ContentView: View {
#State private var buttonEnabled = true
var body: some View {
HStack {
MyButton("Button") { // << here !!
self.buttonEnabled.toggle()
print("Button pressed")
}
.disabled(!buttonEnabled) // << here !!
}
}
}
Again using the Environment.isEnabled but this time using a ViewModifier. This has the advantage that you can use it on other Views, not just Buttons. This implementation reduces the opacity of the button background color so no need for a new style to be injected.
struct MyButtonModifier: ViewModifier {
#Environment(\.isEnabled) var isEnabled
let backgroundColor: Color
func body(content: Content) -> some View {
content
.background(backgroundColor.opacity(isEnabled ? 1 : 0.5))
}
}
Then use it in your code as
Button("foo") {
// action
}
.modifier(MyButtonModifier(backgroundColor: Color.red))

Disable a segment in a SwiftUI SegmentedPickerStyle Picker?

My SwiftUI app has a segmented Picker and I want to be able to disable one or more options depending on availability of options retrieved from a network call. The View code looks something like:
#State private var profileMetricSelection: Int = 0
private var profileMetrics: [RVStreamMetric] = [.speed, .heartRate, .cadence, .power, .altitude]
#State private var metricDisabled = [true, true, true, true, true]
var body: some View {
VStack(alignment: .leading, spacing: 2.0) {
...(some views)...
Picker(selection: $profileMetricSelection, label: Text("")) {
ForEach(0 ..< profileMetrics.count) { index in
Text(self.profileMetrics[index].shortName).tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
...(some more views)...
}
}
What I want to be able to do is modify the metricDisabled array based on network data so the view redraws enabling the relevant segments. In UIKit this can be done by calls to setEnabled(_:forSegmentAt:) on the UISegmentedControl but I can't find a way of doing this with the SwiftUI Picker
I know I can resort to wrapping a UISegmentedControl in a UIViewRepresentable but before that I just wanted to check I'm not missing something...
you can use this simple trick
import SwiftUI
struct ContentView: View {
#State var selection = 0
let data = [1, 2, 3, 4, 5]
let disabled = [2, 3] // at index 2, 3
var body: some View {
let binding = Binding<Int>(get: {
self.selection
}) { (i) in
if self.disabled.contains(i) {} else {
self.selection = i
}
}
return VStack {
Picker(selection: binding, label: Text("label")) {
ForEach(0 ..< data.count) { (i) in
Text("\(self.data[i])")
}
}.pickerStyle(SegmentedPickerStyle())
Spacer()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Maybe something like
ForEach(0 ..< data.count) { (i) in
if !self.disabled.contains(i) {
Text("\(self.data[i])")
} else {
Spacer()
}
}
could help to visualize it better
NOTES (based on the discussion)
From user perspective, the Picker is one control, which could be in disabled / enabled state.
The option selected from Picker is not control, it is some value. If you make a list of controls presented to the user, some of them could be disabled, just to inform the user, that the action associated with it is not currently available (like menu, some buttons collection etc.)
I suggest you to show in Picker only values which could be selected. This collection of values could be updated any time.
UPDATE
Do you like something like this?
No problem at all ... (copy - paste - try - modify ...)
import SwiftUI
struct Data: Identifiable {
let id: Int
let value: Int
var disabled: Bool
}
struct ContentView: View {
#State var selection = -1
#State var data = [Data(id: 0, value: 10, disabled: true), Data(id: 1, value: 20, disabled: true), Data(id: 2, value: 3, disabled: true), Data(id: 3, value: 4, disabled: true), Data(id: 4, value: 5, disabled: true)]
var filteredData: [Data] {
data.filter({ (item) -> Bool in
item.disabled == false
})
}
var body: some View {
VStack {
VStack(alignment: .leading, spacing: 0) {
Text("Select from avaialable")
.padding(.horizontal)
.padding(.top)
HStack {
GeometryReader { proxy in
Picker(selection: self.$selection, label: Text("label")) {
ForEach(self.filteredData) { (item) in
Text("\(item.value.description)").tag(item.id)
}
}
.pickerStyle(SegmentedPickerStyle())
.frame(width: CGFloat(self.filteredData.count) * proxy.size.width / CGFloat(self.data.count), alignment: .topLeading)
Spacer()
}.frame(height: 40)
}.padding()
}.background(Color.yellow.opacity(0.2)).cornerRadius(20)
Button(action: {
(0 ..< self.data.count).forEach { (i) in
self.data[i].disabled = false
}
}) {
Text("Enable all")
}
Button(action: {
self.data[self.selection].disabled = true
self.selection = -1
}) {
Text("Disable selected")
}.disabled(selection < 0)
Spacer()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}