SwiftUI promise is to call View’s body only when needed to avoid invalidating views whose State has not changed.
However, there are some cases when this promise is not kept and the View is updated even though its state has not changed.
Example:
struct InsideView: View {
#Binding var value: Int
// …
}
Looking at that view, we’d expect that its body is called when the value changes. However, this is not always true and it depends on how that binding is passed to the view.
When the view is created this way, everything works as expected and InsideView is not updated when value hasn’t changed.
#State private var value: Int = 0
InsideView(value: $value)
In the example below, InsideView will be incorrectly updated even when value has not changed. It will be updated whenever its container is updated too.
var customBinding: Binding<Int> {
Binding<Int> { 100 } set: { _ in }
}
InsideView(value: customBinding)
Can anyone explain this and say whether it's expected? Is there any way to avoid this behaviour that can ultimately lead to performance issues?
Here's a sample project if anyone wants to play with it.
And here's a full code if you just want to paste it to your project:
import SwiftUI
struct ContentView: View {
#State private var tab = 0
#State private var count = 0
#State private var someValue: Int = 100
var customBinding: Binding<Int> {
Binding<Int> { 100 } set: { _ in }
}
var body: some View {
VStack {
Picker("Tab", selection: $tab) {
Text("#Binding from #State").tag(0)
Text("Custom #Binding").tag(1)
}
.pickerStyle(SegmentedPickerStyle())
VStack(spacing: 10) {
if tab == 0 {
Text("When you tap a button, a view below should not be updated. That's a desired behaviour.")
InsideView(value: $someValue)
} else if tab == 1 {
Text("When you tap a button, a view below will be updated (its background color will be set to random value to indicate this). This is unexpected because the view State has not changed.")
InsideView(value: customBinding)
}
}
.frame(width: 250, height: 150)
Button("Tap! Count: \(count)") {
count += 1
}
}
.frame(width: 300, height: 350)
.padding()
}
}
struct InsideView: View {
#Binding var value: Int
var body: some View {
print("[⚠️] InsideView body.")
return VStack {
Text("I'm a child view. My body should be called only once.")
.multilineTextAlignment(.center)
Text("Value: \(value)")
}
.background(Color.random)
}
}
extension ShapeStyle where Self == Color {
static var random: Color {
Color(
red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1)
)
}
}
Hmm i think the reason is being updated is because you are using a computed property in the ContentView view. and even if it's not tagged with a state annotation like #State, #Binding,#Stateobject... its a view state regardless, and swiftui use that to infer the difference between a view with an old state and a new state. And you're getting a new binding object at every contentview body update.
you can try change the init from what you have to something like this
let customBinding: Binding<Int>
init() {
self.customBinding = Binding<Int> { 99 } set: { _ in }
}
but i would advise against this approach because simply is not useful to create a binding like this in the view, because you can't change anything inside the set because it's the init of a struct.
Instead you can pass in the init an ObservableObject where you moved the state logic to an ObservableObject and use that.
something like this
class ContentViewState: ObservableObject {
#Published var someValue: Int = 100
var customBinding: Binding<Int> = .constant(0)
init() {
customBinding = Binding<Int> { [weak self] in
self?.someValue ?? 0
}
set: { [weak self] in
self?.someValue = $0
}
}
}
// and change the InsideView like
struct InsideView: View {
#ObservedObject private var state: ContentViewState
#Binding var value: Int
init(state: ContentViewState) {
self.state = state
_value = state.customBinding
}
...
}
I would still use the $ with simple #state notation most of the time where i don't have complicated states to handle, but this can be another approach i guess.
Related
Working on my first SwiftUI project, and as I started moving some of my more complex views into their own view structs I started getting problems with the views not being redrawn.
As an example, I have the following superview:
struct ContainerView: View {
#State var myDataObject: MyDataObject?
var body: some View {
if let myDataObject = myDataObject {
TheSmallerView(myDataObject: myDataObject)
.padding(.vertical, 10)
.frame(idealHeight: 10)
.padding(.horizontal, 8)
.onAppear {
findRandomData()
}
}
else {
Text("No random data found!")
.onAppear {
findRandomData()
}
}
}
private func findRandomData() {
myDataObject = DataManager.shared.randomData
}
}
Now when this first gets drawn I get the Text view on screen as the myDataObject var is nil, but the .onAppear from that gets called, and myDataStruct gets set with an actual struct. I've added breakpoints in the body variable, and I see that when this happens it gets called again and it goes into the first if clause and fetches the "TheSmallerView" view, but nothing gets redrawn on screen. It still shows the Text view from before.
What am I missing here?
EDIT: Here's the relevant parts of TheSmallerView:
struct TheSmallerView: View {
#ObservedObject var myDataObject: MyDataObject
EDIT2: Fixed the code to better reflect my actual code.
Try declaring #Binding var myDataStruct: MyDataStruct inside the TheSmallerView view and pass it like this: TheSmallerView(myDataStruct: $myDataStruct) from ContainerView
You are using #ObservedObject in the subview, but that property wrapper is only for classes (and your data is a struct).
You can use #State instead (b/c the data is a struct).
Edit:
The data isn't a struct.
Because it is a class, you should use #StateObject instead of #State.
In lack of complete code I created this simple example based on OPs code, which works fine the way it is expected to. So the problem seems to be somewhere else.
class MyDataObject: ObservableObject {
#Published var number: Int
init() {
number = Int.random(in: 0...1000)
}
}
struct ContentView: View {
#State var myDataObject: MyDataObject?
var body: some View {
if let myDataObject = myDataObject {
TheSmallerView(myDataObject: myDataObject)
.onAppear {
findRandomData()
}
}
else {
Text("No random data found!")
.onAppear {
findRandomData()
}
}
}
private func findRandomData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
myDataObject = MyDataObject()
}
}
}
struct TheSmallerView: View {
#ObservedObject var myDataObject: MyDataObject
var body: some View {
Text("The number is: \(myDataObject.number)")
}
}
I have an issue with the SwiftUI system layout on Xcode 12.5.1
On a Text View, I'm using a custom transition(), which is based on custom ViewModifiers. In addition, I'm also using a custom id() that allow me to tell the system that this Text View is not the same, therefore triggering a transition animation.
When using these 2 modifiers on a view, its parent and the content itself doesn't respect the system layout expected behaviour. You can see exactly what happens in this video
Expected behaviour
Actual behaviour
Here is my sample project code that you can test yourself :
import SwiftUI
struct ContentView: View {
private let contentArray: [String] = ["QWEQWE", "ASDASD", "TOTO", "FOO", "BAR"]
#State private var content: String = "content"
var body: some View {
VStack {
VStack {
Text(self.content)
// commenting either the transition modifier, or the id modifier, make the system layout work as expected. The problem comes from using both at the same time
.transition(.customTransition)
.id(self.content)
}
.background(Color.blue)
Button("action") {
guard let newValue = self.contentArray.randomElement() else {
print("Failed to get a random element from property contentArray")
return
}
withAnimation {
self.content = newValue
}
}
}
.background(Color.green)
}
}
extension AnyTransition {
static var customTransition: AnyTransition {
// uncomment the following line make the system layout work as expected. The problem comes from AnyTransition.modifier(active:identity:)
// return AnyTransition.slide.combined(with: .opacity)
let outAnimation = AnyTransition.modifier(active: CustomTrailingOffset(percent: 1), identity: CustomTrailingOffset(percent: 0)).combined(with: .opacity)
let inAnimation = AnyTransition.modifier(active: CustomLeadingOffset(percent: 1), identity: CustomLeadingOffset(percent: 0)).combined(with: .opacity)
return AnyTransition.asymmetric(insertion: inAnimation, removal: outAnimation)
}
}
struct CustomLeadingOffset: ViewModifier {
var percent: CGFloat = 0
func body(content: Content) -> some View {
GeometryReader { geo in
content.offset(CGSize(width: -geo.size.width*self.percent, height: 0.0))
}
}
}
struct CustomTrailingOffset: ViewModifier {
var percent: CGFloat = 0
func body(content: Content) -> some View {
GeometryReader { geo in
content.offset(CGSize(width: geo.size.width*self.percent, height: 0.0))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewLayout(.device)
.previewDevice("iPhone 12")
}
}
Tested on Xcode 12.5.1
I've found nothing regarding this behaviour during my researches, not on blog posts, not on forums, not on official documentation, that's the reason why I'm posting this question here, in case anyone ever encounter this problem, knows why it occurs, or knows a solution. I've fill a radar (FB9605660) to Apple in parallel but I'm not sure if I'll be informed to their conclusions.
The following code contains a list of text and a button to add more texts. When an item is added, the scroll view should scroll to the second to last (for simplicity in this example) item.
import SwiftUI
struct ContentView: View {
#StateObject private var viewModel = ViewModel()
var body: some View {
createView()
}
private func createView() -> some View {
return VStack {
ScrollView {
ScrollViewReader { scrollProxy in
VStack {
ForEach(viewModel.ids, id: \.self) { id in
Text(viewModel.texts[id]!)
.padding()
}
}.onAppear {
viewModel.scrollProxy = scrollProxy
}
}
}
Button("Add") {
viewModel.onAdd()
}.padding()
}
}
}
class ViewModel: ObservableObject {
#Published var ids: [UUID]
#Published var texts: [UUID: String]
#Published var responderID: UUID?
var scrollProxy: ScrollViewProxy?
init() {
let id = UUID()
self.ids = [id]
self.texts = [id: "0"]
}
func onAdd() {
let lastID = ids[ids.count - 1]
// create new block
let newID = UUID()
ids.append(newID)
self.texts[newID] = String(ids.count)
withAnimation {
scrollProxy?.scrollTo(lastID)
}
}
}
When tapping the button slowly, the scroll animation works fine.
But when the button is tapped quickly, the animation is ... broken? The animation slows to a crawl, taking a long time to complete.
So my question is, is there another way to animation the scrolling? I've tried using CADisplayLink, but the result is also pretty bad...
I made a post about this yesterday and apologize for it not being clear or descriptive enough. Today I've made some more progress on the problem but still haven't found a solution.
In my program I have a main view called GameView(), a view called KeyboardView() and a view called ButtonView().
A ButtonView() is a simple button that displays a letter and when pressed tells the keyboardView it belongs to what letter it represents. When it's pressed it is also toggled off so that it cannot be pressed again. Here is the code.
struct ButtonView: View {
let impactFeedbackgenerator = UIImpactFeedbackGenerator(style: .medium)
var letter:String
var function:() -> Void
#State var pressed = false
var body: some View {
ZStack{
Button(action: {
if !self.pressed {
self.pressed = true
self.impactFeedbackgenerator.prepare()
self.impactFeedbackgenerator.impactOccurred()
self.function()
}
}, label: {
if pressed{
Text(letter)
.font(Font.custom("ComicNeue-Bold", size: 30))
.foregroundColor(.white)
.opacity(0.23)
} else if !pressed{
Text(letter)
.font(Font.custom("ComicNeue-Bold", size: 30))
.foregroundColor(.white)
}
})
}.padding(5)
}
}
A keyboard view is a collection of ButtonViews(), one for each button on the keyboard. It tells the GameView what button has been pressed if a button is pressed.
struct KeyboardView: View {
#Environment(\.parentFunction) var parentFunction
var topRow = ["Q","W","E","R","T","Y","U","I","O","P"]
var midRow = ["A","S","D","F","G","H","J","K","L"]
var botRow = ["Z","X","C","V","B","N","M"]
var body: some View {
VStack{
HStack(){
ForEach(0..<topRow.count, id: \.self){i in
ButtonView(letter: self.topRow[i], function: {self.makeGuess(self.topRow[i])})
}
}
HStack(){
ForEach(0..<midRow.count, id: \.self){i in
ButtonView(letter: self.midRow[i], function: {self.makeGuess(self.midRow[i])})
}
}
HStack(){
ForEach(0..<botRow.count, id: \.self){i in
ButtonView(letter: self.botRow[i], function: {self.makeGuess(self.botRow[i])})
}
}
}
}
func makeGuess(_ letter:String){
print("Keyboard: Guessed \(letter)")
self.parentFunction?(letter)
}
}
Finally a GameView() is where the keyboard belongs. It displays the keyboard along with the rest of the supposed game.
struct GameView: View {
#Environment(\.presentationMode) var presentation
#State var guessedLetters = [String]()
#State var myKeyboard:KeyboardView = KeyboardView()
var body: some View {
ZStack(){
Image("Background")
.resizable()
.edgesIgnoringSafeArea(.all)
.opacity(0.05)
VStack{
Button("New Game") {
self.newGame()
}.font(Font.custom("ComicNeue-Bold", size: 20))
.foregroundColor(.white)
.padding()
self.myKeyboard
.padding(.bottom, 20)
}
}.navigationBarTitle("")
.navigationBarBackButtonHidden(true)
.navigationBarHidden(true)
.environment(\.parentFunction, parentFunction)
}
func makeGuess(_ letter:String){
self.guessedLetters.append(letter)
}
func newGame(){
print("Started a new game.")
self.guessedLetters.removeAll()
self.myKeyboard = KeyboardView()
}
func parentFunction(_ letter:String) {
makeGuess(letter)
}
}
struct ParentFunctionKey: EnvironmentKey {
static let defaultValue: ((_ letter:String) -> Void)? = nil
}
extension EnvironmentValues {
var parentFunction: ((_ letter:String) -> Void)? {
get { self[ParentFunctionKey.self] }
set { self[ParentFunctionKey.self] = newValue }
}
}
The issue is that when I start a new game, the array is reset but not keyboardView(), the buttons that have been toggled off remain off, but since it's being replaced by a new keyboardView() shouldn't they go back to being toggled on?
I'll repeat what I said in an answer to your previous question - under most normal use cases you shouldn't instantiate views as variables, so if you find yourself doing that, you might be on the wrong track.
Whenever there's any state change, SwiftUI recomputes the body and reconstructs the view tree, and matches the child view states to the new tree.
When it detects that something has changed, it realizes that the new child view is truly new, so it resets its state, fires .onAppear and so forth. But when there's no change that it can detect, then it just keeps the same state for all the descendent views.
That's what you're observing.
Specifically, in your situation nothing structurally has changed - i.e. it's still:
GameView
--> KeyboardView
--> ButtonView
ButtonView
ButtonView
...
so, it keeps the state of ButtonViews as is.
You can signal to SwiftUI that the view has actually changed and that it should be updated by using an .id modifier (documentation isn't great, but you can find more info in blogs), where you need to supply any Hashable variable to it that's different than the current one, in order to reset it.
I'll use a new Bool variable as an example:
struct GameView {
#State var id: Bool = false // initial value doesn't matter
var body: some View {
VStack() {
KeyboardView()
.id(id) // use the id here
Button("new game") {
self.id.toggle() // changes the id
}
}
}
}
Every time the id changes, SwiftUI resets the state, so all the child views', like ButtonViews', states are reset.
I'm trying to use SwiftUI to build a reusable component I previously implemented with #IBDesignable, but it proving to be more difficult than I would have imagined. The problems are a) initializing the text variable and b) clipping the count value. See the code below.
I've tried modifying the value of the text variable both in an initializer and within the body closure, based on changes in the count value, but neither seems to be allowed.
The limiting of the count value and the initialization of the text variable need to happen both from within the view and also when the client of the view modifies the count value. I don't have a clue how to go about making this happen when the count is modified by the client view
#Binding var count: Int
#State var text : String = ""
var maxVal = 5
var minVal = -5
var body: some View {
VStack {
TextField($text,
onEditingChanged: validateString
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
HStack {
Button(
action: { self.add(1) },
label: { Text("Plus")}
)
Button(
action: { self.add(-1) },
label: { Text("Minus")}
)
}
}
}
func setVal(_ num: Int) {
count = min(max(num, minVal), maxVal)
if text != String(count) { text = String(count) }
}
func validateString(_ flag: Bool) {
if !flag {
guard let num = Int(text) else { return }
setVal(num)
}
}
func add(_ increment: Int) {
setVal(count + increment)
}
}
If I understood all the internal details of binding and of how SwiftUI rebuilds views, I'm sure I could figure this out. But this is one of the downsides of creating and automatic "it just works" frameworks. I'm very excited about SwiftUI and am hoping to surmount this hurdle in understanding.
You left some parts of the code out, specially how you are trying to initialize your view. But this should get you started.
Also note that onEditingChanged won't be called until you leave the textField (or hide the keyboard), so clipping won't happen until then.
Also I noticed you are using an older TextField initializer that has been deprecated already. I updated to its new version.
import SwiftUI
struct ContentView : View {
#State private var count = 3
var body: some View {
MyView(count: $count)
}
}
struct MyView: View {
#Binding var count: Int
#State private var text : String
var maxVal = 5
var minVal = -5
init(count: Binding<Int>) {
self._count = count
self._text = State(initialValue: "\(count.value)")
}
var body: some View {
VStack {
TextField("", text: $text, onEditingChanged: validateString)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity, maxHeight: .infinity)
HStack {
Button(
action: { self.add(1) },
label: { Text("Plus")}
)
Button(
action: { self.add(-1) },
label: { Text("Minus")}
)
}
}
}
func setVal(_ num: Int) {
count = min(max(num, minVal), maxVal)
if text != String(count) { text = String(count) }
}
func validateString(_ flag: Bool) {
if !flag {
guard let num = Int(text) else { return }
setVal(num)
}
}
func add(_ increment: Int) {
setVal(count + increment)
}
}
So, basically you forgot to add a init() for your reusable component. By default, structs are created with their own initializer for the properties that are declared in those structs. But If you want to build a reusable component you need to add a init(). It helped me as I was creating a custom library of reusable SwiftUI components and then using it in my project. So, My public struct in my library had to include a init().