How to toggle animation in View from outside with preparation in SwiftUI? - swiftui

I'm building a UI component with SwiftUI that should have trigger from outside to turn on animation and some inner preparations for it. In examples below it's prepareArray() function.
My first approach was to use bindings, but I've found that there is no way to listen when #Binding var changes to trigger something:
struct ParentView: View {
#State private var animated: Bool = false
var body: some View {
VStack {
TestView(animated: $animated)
Spacer()
Button(action: {
self.animated.toggle()
}) {
Text("Toggle")
}
Spacer()
}
}
}
struct TestView: View {
#State private var array = [Int]()
#Binding var animated: Bool {
didSet {
prepareArray()
}
}
var body: some View {
Text("\(array.count): \(animated ? "Y" : "N")").background(animated ? Color.green : Color.red).animation(Animation.easeIn(duration: 0.5).delay(0.1))
}
private func prepareArray() {
array = [1]
}
}
Why then it allows didSet listener for #Binding var if it's not working?! Then I switched to simple Combine signal since it's can be caught in onReceive closure. But #State on signal was not invalidating view on value pass:
struct ParentView: View {
#State private var animatedSignal = CurrentValueSubject<Bool, Never>(false)
var body: some View {
VStack {
TestView(animated: animatedSignal)
Spacer()
Button(action: {
self.animatedSignal.send(!self.animatedSignal.value)
}) {
Text("Toggle")
}
Spacer()
}
}
}
struct TestView: View {
#State private var array = [Int]()
#State var animated: CurrentValueSubject<Bool, Never>
var body: some View {
Text("\(array.count): \(animated.value ? "Y" : "N")").background(animated.value ? Color.green : Color.red).animation(Animation.easeIn(duration: 0.5).delay(0.1)).onReceive(animated) { animated in
if animated {
self.prepareArray()
}
}
}
private func prepareArray() {
array = [1]
}
}
So my final approach was to trigger inner state var on signal value:
struct ParentView: View {
#State private var animatedSignal = CurrentValueSubject<Bool, Never>(false)
var body: some View {
VStack {
TestView(animated: animatedSignal)
Spacer()
Button(action: {
self.animatedSignal.send(!self.animatedSignal.value)
}) {
Text("Toggle")
}
Spacer()
}
}
}
struct TestView: View {
#State private var array = [Int]()
let animated: CurrentValueSubject<Bool, Never>
#State private var animatedInnerState: Bool = false {
didSet {
if animatedInnerState {
self.prepareArray()
}
}
}
var body: some View {
Text("\(array.count): \(animatedInnerState ? "Y" : "N")").background(animatedInnerState ? Color.green : Color.red).animation(Animation.easeIn(duration: 0.5).delay(0.1)).onReceive(animated) { animated in
self.animatedInnerState = animated
}
}
private func prepareArray() {
array = [1]
}
}
Which works fine, but I can't believe such a simple task requires so complicated construct! I know that SwiftUI is declarative, but may be I'm missing more simple approach for this task? Actually in real code this animated trigger will have to be passed to one more level deeper(

It is possible to achieve in many ways, including those you tried. Which one to choose might depend on real project needs. (All tested & works Xcode 11.3).
Variant 1: modified your first try with #Binding. Changed only TestView.
struct TestView: View {
#State private var array = [Int]()
#Binding var animated: Bool
private var myAnimated: Binding<Bool> { // internal proxy binding
Binding<Bool>(
get: { // called whenever external binding changed
self.prepareArray(for: self.animated)
return self.animated
},
set: { _ in } // here not used, so just stub
)
}
var body: some View {
Text("\(array.count): \(myAnimated.wrappedValue ? "Y" : "N")")
.background(myAnimated.wrappedValue ? Color.green : Color.red).animation(Animation.easeIn(duration: 0.5).delay(0.1))
}
private func prepareArray(for animating: Bool) {
DispatchQueue.main.async { // << avoid "Modifying state during update..."
self.array = animating ? [1] : [Int]() // just example
}
}
}
Variant2 (my preferable): based on view model & publishing, but requires changes both ParentView and TestView, however in general simpler & clear.
class ParentViewModel: ObservableObject {
#Published var animated: Bool = false
}
struct ParentView: View {
#ObservedObject var vm = ParentViewModel()
var body: some View {
VStack {
TestView()
.environmentObject(vm) // alternate might be via argument
Spacer()
Button(action: {
self.vm.animated.toggle()
}) {
Text("Toggle")
}
Spacer()
}
}
}
struct TestView: View {
#EnvironmentObject var parentModel: ParentViewModel
#State private var array = [Int]()
var body: some View {
Text("\(array.count): \(parentModel.animated ? "Y" : "N")")
.background(parentModel.animated ? Color.green : Color.red).animation(Animation.easeIn(duration: 0.5).delay(0.1))
.onReceive(parentModel.$animated) {
self.prepareArray(for: $0)
}
}
private func prepareArray(for animating: Bool) {
self.array = animating ? [1] : [Int]() // just example
}
}

Related

#EnvironmentObject bug in SwiftUI

When I press the Move button in the contextMenu, I change the isCopied and setOriginPath variables in the EnvironmentObject. When this change is made, the List view is cleared and I can't see anything on the screen. I don't have any problems when I don't use EnvironmentObject.
ContextMenu:
.contextMenu {
Button {
safeFileVM.hideSelectedFile(fileName: currentFile.fileName)
safeFileVM.takeArrayOfItems()
} label: {
HStack {
Text(!currentFile.isLock ? "Hide" : "Show")
Image(systemName: currentFile.isLock ? "eye" : "eye.slash")
}
}
Button {
safeFileClipboard.setOriginPath = URL(fileURLWithPath: currentFile.localPath)
safeFileClipboard.isCopied = true
} label: {
HStack {
Text("Move")
Image(systemName: "arrow.up.doc")
}
}
}
View:
struct DetailObjectView: View {
#ObservedObject var safeFileVM: SafeFileViewModel = SafeFileViewModel()
#EnvironmentObject var safeFileClipboard: SafeFileClipBoard
var currentFile: MyFile
var currentLocation = ""
var body: some View {
VStack {
.....
}
.contextMenu {
Button {
safeFileVM.hideSelectedFile(fileName: currentFile.fileName)
safeFileVM.takeArrayOfItems()
} label: {
HStack {
Text(!currentFile.isLock ? "Hide" : "Show")
Image(systemName: currentFile.isLock ? "eye" : "eye.slash")
}
}
Button {
safeFileClipboard.setOriginPath = URL(fileURLWithPath: currentFile.localPath)
safeFileClipboard.isCopied = true
} label: {
HStack {
Text("Move")
Image(systemName: "arrow.up.doc")
}
}
}
}
}
In the mini project below, when the EnvironmentObject value changes, navigation goes to the beginning. Why ? How can I fix this ?
Example Project:
Main:
#main
struct EnvironmentTestApp: App {
#StateObject var fooConfig: FooConfig = FooConfig()
var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
.environmentObject(fooConfig)
}
}
}
}
ContentView:
struct ContentView: View {
#EnvironmentObject var fooConfig: FooConfig
private let numbers: [Number] = [.init(item: "1"), .init(item: "2"), .init(item: "3")]
var body: some View {
List {
ForEach(numbers, id: \.id) { item in
DetailView(itemNumber: item.item)
}
}
}
}
struct Number: Identifiable {
var id = UUID()
var item: String
}
DetailView:
struct DetailView: View {
#EnvironmentObject var fooConfig: FooConfig
var itemNumber: String = ""
var body: some View {
NavigationLink(destination: ContentView().environmentObject(fooConfig)) {
Text("\(itemNumber) - \(fooConfig.fooBool == true ? "On" : "Off")")
.environmentObject(fooConfig)
.contextMenu {
Button {
fooConfig.fooBool.toggle()
} label: {
HStack {
Text(fooConfig.fooBool != true ? "On" : "Off")
}
}
}
}
}
}
ObservableObject:
class FooConfig: ObservableObject {
#Published var fooBool: Bool = false
}
Move that from scene into ContentView, because scene is a bad place to update view hierarchy, it is better to do inside view hierarchy, so here
struct EnvironmentTestApp: App {
var body: some Scene {
WindowGroup {
ContentView() // only root view here !!
}
}
}
everything else is inside views, like
struct ContentView: View {
#StateObject private var foo = FooConfig()
var body: some View {
NavigationView {
MainView()
.environmentObject(foo) // << here !!
}
}
}
struct MainView: View {
#EnvironmentObject var fooConfig: FooConfig
private let numbers: [Number] = [.init(item: "1"), .init(item: "2"), .init(item: "3")]
var body: some View {
List {
ForEach(numbers, id: \.id) { item in
DetailView(itemNumber: item.item)
}
}
}
}
and so on...
Tested with Xcode 14 / iOS 16

Calling Method on Child View in SwiftUI

I have an ImageEditView that contains an ImageCanvasView and an ImageCaptureButton.
Ideally, I want ImageCaptureButton to call a method on ImageCanvasView called takeScreenshot.
How do I achieve this in SwiftUI? I've been thinking of trying to save ImageCanvasView into a variable in ImageEditView so that my ImageCaptureButton can then call its method, but SwiftUI's declarative nature means this isn't possible.
----- EDIT below -----
The flow is as follows:
ImageSelectView (user selects an image)
ImageEditView (user edits an image) - this view contains ImageCanvasView and ImageCaptureButton
ImageShareView (user shares the image)
The following is ImageEditView
import SwiftUI
struct ImageEditView: View {
#State var selectedImage: Image
#State private var isNavLinkPresented = false
#State private var imageSnapshot: UIImage = UIImage()
var canvasView: ImageCanvasView = ImageCanvasView(selectedImage: $selectedImage)
// this won't work: Cannot use instance member '$selectedImage' within property initializer; property initializers run before 'self' is available
var body: some View {
VStack {
canvasView
Spacer()
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink(destination: ImageShareView(imageToShare: $imageSnapshot), isActive: $isNavLinkPresented) {
Text("Next")
.onTapGesture {
imageSnapshot = canvasView.takeScreenshot()
isNavLinkPresented = true
}
}
}
}
.navigationBarTitle(Text("Edit image"))
}
}
You are in the right direction. By creating var of the view, you can call the function.
Here is the example demo
struct ImageEditView: View {
var canvasView: ImageCanvasView = ImageCanvasView()
var body: some View {
VStack {
Text("ImageEditView")
canvasView
Button("ImageCaptureButton") {
canvasView.takeScreenshot()
}
}
}
}
struct ImageCanvasView: View {
var body: some View {
Text("ImageCanvasView")
}
func takeScreenshot() {
print(#function + " Tapped ")
}
}
You can also use computed property to pass data
struct ImageEditView: View {
var data: String = ""
var canvasView: ImageCanvasView {
ImageCanvasView(data: data)
}
// Body code
}
struct ImageCanvasView: View {
var data: String
var body: some View {
Text("ImageCanvasView")
}
func takeScreenshot() {
print(#function + " Tapped ")
}
}
EDIT
Use init to use the same instance of ImageCanvasView.
struct ImageEditView: View {
#State var selectedImage: Image
#State private var isNavLinkPresented = false
#State private var imageSnapshot: UIImage = UIImage()
var canvasView: ImageCanvasView!
init(selectedImage: Image) {
self.selectedImage = selectedImage
canvasView = ImageCanvasView(selectedImage: $selectedImage)
}
// Other code

How could be change children #State property value from parent view in SwiftUI?

Given this code:
import SwiftUI
struct ContentView: View {
#State private var someView = DefaultView()
var body: some View {
VStack { someView }
//.onAppear { someView.change() } //1. Color not changing from here, isDisplay remains false
}
}
struct DefaultView: View {
#State private var isDisplay = false
func change() {
isDisplay = true
print("isDisplay = \(self.isDisplay)")
}
var body: some View {
Text("Hello!")
.foregroundColor(isDisplay ? .red : .blue)
//.onAppear { change() } //2. Color and isDisplay property is changing from here well
}
}
I'd like to call change func from parent view (ContentView 1.). That runs because the print is visible on the debug area, but there is no any changes, the isDisplay property does not change from false to true.
Why does this work that way? How can I change the #state property from the parent class?
Edit:
It could work:
struct DefaultView: View {
public var isDisplay: Bool
init() {
isDisplay = false
}
mutating func change() {
isDisplay = true
print("isDisplay = \(self.isDisplay)")
}
var body: some View {
Text("Hello!")
.foregroundColor(isDisplay ? .red : .blue)
}
}
The way you showed your code in question is not the way SwiftUI works, you are thinking in UIKit and coding SwiftUI?
Once you fired a View there is no return to use the imbedded function or any thing! SwiftUI-View is more like a gun build you can not change the direction or other property of it, after render is done! in the other hand UIKit-View is more like Rocket, after firing you have control on it and you can change destination and more other things as well. So there is deference between them, make your build lighter as possible. Because your going firing lots and SwiftUI should handel them as much as easily as possible.
Here the right way:
struct ContentView: View {
#State private var isDisplay: Bool = Bool()
var body: some View {
DefaultView(isDisplay: isDisplay)
Button("update") { isDisplay.toggle() }.padding()
}
}
struct DefaultView: View {
let isDisplay: Bool
var body: some View {
Text("Hello!")
.foregroundColor(isDisplay ? .red : .blue)
}
}
I would use #Binding to change the value of the property in your DefaultView from a parent view. Here is a brief example.
struct ContentView: View {
#State private var changeColor = false
var body: some View {
VStack {
Button {
changeColor.toggle()
} label: {
Text("Toggle Button")
.padding()
.foregroundColor(.red)
}
DefaultView(isDisplay: $changeColor)
}
}
}
struct DefaultView: View {
#Binding var isDisplay: Bool
var body: some View {
Text("Hello")
.foregroundColor(isDisplay ? .red : .blue)
}
}

#State var doesn't store value

My goal is to pass values between views, from Chooser to ThemeEditor. When the user presses an icon, I'm saving the object that I want to pass and later, using sheet and passing the newly created view with the content of the #State var.
The assignment is done successfully to the #State var themeToEdit, however it is nil when the ThemeEditor view is created in the sheet
What am I doing wrong?
struct Chooser: View {
#EnvironmentObject var store: Store
#State private var showThemeEditor = false
#State private var themeToEdit: ThemeContent?
#State private var editMode: EditMode = .inactive
#State private var isValid = false
var body: some View {
NavigationView {
List {
ForEach(self.store.games) { game in
NavigationLink(destination: gameView(game))
{
Image(systemName: "wrench.fill")
.imageScale(.large)
.opacity(editMode.isEditing ? 1 : 0)
.onTapGesture {
self.showThemeEditor = true
/* themeInfo is of type struct ThemeContent: Codable, Hashable, Identifiable */
self.themeToEdit = game.themeInfo
}
VStack (alignment: .leading) {
Text(self.store.name(for: something))
HStack{
/* some stuff */
Text(" of: ")
Text("Interesting info")
}
}
}
}
.sheet(isPresented: $showThemeEditor) {
if self.themeToEdit != nil { /* << themeToEdit is nil here - always */
ThemeEditor(forTheme: self.themeToEdit!, $isValid)
}
}
}
.environment(\.editMode, $editMode)
}
}
}
struct ThemeEditor: View {
#State private var newTheme: ThemeContent
#Binding var isValid: Bool
#State private var themeName = ""
init(forTheme theme: ThemeContent, isValid: Binding<Bool>) {
self._newTheme = State(wrappedValue: theme)
self._validThemeEdited = isValid
}
var body: some View {
....
}
}
struct ThemeContent: Codable, Hashable, Identifiable {
/* stores simple typed variables of information */
}
The .sheet content view is captured at the moment of creation, so if you want to check something inside, you need to use .sheet(item:) variant, like
.sheet(item: self.$themeToEdit) { item in
if item != nil {
ThemeEditor(forTheme: item!, $isValid)
}
}
Note: it is not clear what is ThemeContent, but it might be needed to conform it to additional protocols.
Use Binding. Change your ThemeEditor view with this.
struct ThemeEditor: View {
#Binding private var newTheme: ThemeContent?
#Binding var isValid: Bool
#State private var themeName = ""
init(forTheme theme: Binding<ThemeContent?>, isValid: Binding<Bool>) {
self._newTheme = theme
self._isValid = isValid
}
var body: some View {
....
}
}
And for sheet code
.sheet(isPresented: $showThemeEditor) {
ThemeEditor(forTheme: $themeToEdit, isValid: $isValid)
}
On Action
.onTapGesture {
/* themeInfo is of type struct ThemeContent: Codable, Hashable, Identifiable */
self.themeToEdit = game.themeInfo
self.showThemeEditor = true
}

Refreshing a SwiftUI List

Ím trying to refresh this List whenever I click on a NavLink
NavigationView {
List(feed.items.indices, id:\.self) { i in
NavigationLink(destination: ListFeedItemDetail(idx: i).environmentObject(self.feed)) {
ListFeedItem(item: self.$feed.items[i])
}
}
}
The list is made out of an array inside an environment object.
The problem: It does only refresh when I switch to another tab or close the app
I had used a modal View before and it worked there. (I did it with .onAppear)
Any Ideas?
Example
Problem: When you tap on an item in the list and tap the toggle button the EnvironmentObject is changed but this changed is only reflected when I change the tab and change it back again
import SwiftUI
import Combine
struct TestView: View {
#State var showSheet: Bool = false
#EnvironmentObject var feed: TestObject
func addObjects() {
var strings = ["one","two","three","four","five","six"]
for s in strings {
var testItem = TestItem(text: s)
self.feed.items.append(testItem)
}
}
var body: some View {
TabView {
NavigationView {
List(feed.items.indices, id:\.self) { i in
NavigationLink(destination: detailView(feed: self._feed, i: i)) {
HStack {
Text(self.feed.items[i].text)
Text("(\(self.feed.items[i].read.description))")
}
}
}
}
.tabItem({ Text("Test") })
.tag(0)
Text("Blank")
.tabItem({ Text("Test") })
.tag(0)
}.onAppear {
self.addObjects()
}
}
}
struct detailView: View {
#EnvironmentObject var feed: TestObject
var i: Int
var body: some View {
VStack {
Text(feed.items[i].text)
Text(feed.items[i].read.description)
Button(action: { self.feed.items[self.i].isRead.toggle() }) {
Text("Toggle read")
}
}
}
}
final class TestItem: ObservableObject {
init(text: String) {
self.text = text
self.isRead = false
}
static func == (lhs: TestItem, rhs: TestItem) -> Bool {
lhs.text < rhs.text
}
var text: String
var isRead: Bool
let willChange = PassthroughSubject<TestItem, Never>()
var read: Bool {
set {
self.isRead = newValue
}
get {
self.isRead
}
}
}
class TestObject: ObservableObject {
var willChange = PassthroughSubject<TestObject, Never>()
#Published var items: [TestItem] = [] {
didSet {
willChange.send(self)
}
}
}
I had a similar problem, this is the hack I came up with.
In your "TestView" declare:
#State var needRefresh: Bool = false
Pass this to your "detailView" destination, such as:
NavigationLink(destination: detailView(feed: self._feed, i: i, needRefresh: self.$needRefresh)) {
HStack {
Text(self.feed.items[i].text)
Text("(\(self.feed.items[i].read.description))")
}.accentColor(self.needRefresh ? .white : .black)
}
Note ".accentColor(self.needRefresh ? .white : .black)" to force a refresh when "needRefresh"
is changed.
In your "detailView" destination add:
#Binding var needRefresh: Bool
Then in your "detailView" in your Button action, add:
self.needRefresh.toggle()