I have a LandingView
that has a viewModel which is an ObservableObject. LandingView loads some CardView
s dynamically and passes a model object (Binding property).
CardView
further nests four different subviews and passes the attributes needed through Binding. There are text fields in those four subviews. When the user updates the textfield data, the updated data travels back to viewModel through Binding.
Now the problem is that when the user keys in a single character in any of those text fields, the viewmodel is updated and hence the LandingView is re-drawn which leads to the text field losing focus and user has to tap on the text field again.
Is there a way to fix this? I know that I can fix it by getting rid of the Binding properties and have some other mechanism to maintain data flow. But, can I fix it in the current setup itself?
Thanks much in advance!!!!
struct LandingView: View {
@StateObject var viewModel = LandingViewModel(gridDataArray: [])
@State var name: String = ""
var body: some View {
let _ = Self._printChanges()
ZStack {
Color(red: 242/255, green: 242/255, blue: 242/255)
listView
.background(Color.white)
.padding()
}
}
private var listView: some View {
ScrollView {
VStack {
ForEach(Array(viewModel.gridDataArray.enumerated()), id: \.element) { index, element in
CardView(dataModel: $viewModel.gridDataArray[index])
.equatable()
}
}
}
}
}
struct CardView: View, Equatable {
static func == (lhs: CardView, rhs: CardView) -> Bool {
lhs.dataModel.hashValue == rhs.dataModel.hashValue
}
@Binding var dataModel: LandingViewDataModel
var body: some View {
VStack(spacing: 0) {
NavyBlueView(productName: $dataModel.productName,
productId: $dataModel.productId,
productLabel: dataModel.productLabel)
YellowView(weightedProductPrice: $dataModel.weightedProductPrice,
wacSubWACUnitPrice: $dataModel.wacSubWACUnitPrice,
pvpUnitPrice: $dataModel.pvpUnitPrice)
SkyBlueView(weightedPriceUsagePercent: $dataModel.weightedPriceUsagePercent,
wacSubWACUnitPricePercent: $dataModel.wacSubWACUnitPricePercent,
pvpUnitPricePercent: $dataModel.pvpUnitPricePercent,
totalPercent: $dataModel.totalPercent)
GreenView(weightedPriceCalculated: $dataModel.weightedPriceCalculated,
wacSubWACUnitPriceCalculated: $dataModel.wacSubWACUnitPriceCalculated,
pvpUnitPriceCalculated: $dataModel.pvpUnitPriceCalculated,
totalCalculated: $dataModel.totalCalculated)
}
}
}
struct NavyBlueView: View {
@Binding var productName: String
@Binding var productId: String?
var productLabel: String
var body: some View {
VStack(alignment: .leading) {
Text(productLabel)
.padding()
.padding(.bottom, -10)
.foregroundStyle(Color.white)
.frame(maxWidth: .infinity, alignment: .leading)
HStack {
Spacer().frame(width: 10)
TextField("Product Name", text: $productName)
.textFieldStyle(.plain)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Rectangle().fill(Color.white))
.frame(width: 500, height: 35)
.padding(.leading, 15)
.padding(.bottom, 10)
HStack {
Text(" ")
.frame(maxWidth: .infinity)
Text("Weighted Price")
.frame(maxWidth: .infinity)
Text("WAC/SubWAC Unit Price")
.frame(maxWidth: .infinity)
Text("340B/PVP Unit Price")
.frame(maxWidth: .infinity)
Text("Total")
.frame(maxWidth: .infinity)
}
.foregroundStyle(Color.white)
.padding(.bottom, 10)
}
.background(Color(red: 41 / 255, green: 78 / 255, blue: 124 / 255))
.clipShape(.rect(
topLeadingRadius: 10,
bottomLeadingRadius: 0,
bottomTrailingRadius: 0,
topTrailingRadius: 10)
)
.padding(.horizontal)
}
}
struct YellowView: View {
@Binding var weightedProductPrice: String
@Binding var wacSubWACUnitPrice: String
@Binding var pvpUnitPrice: String
var body: some View {
HStack {
Group {
Text("Product Price")
HStack {
Spacer().frame(width: 10)
TextField("Product price", text: $weightedProductPrice)
.textFieldStyle(.plain)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Rectangle().fill(Color.white))
.frame(height: 35)
HStack {
Spacer().frame(width: 10)
TextField("Product price", text: $wacSubWACUnitPrice)
.textFieldStyle(.plain)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Rectangle().fill(Color.white))
.frame(height: 35)
.padding(10)
HStack {
Spacer().frame(width: 10)
TextField("Product price", text: $pvpUnitPrice)
.textFieldStyle(.plain)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Rectangle().fill(Color.white))
.frame(height: 35)
Text("")
.frame(maxWidth: .infinity)
.frame(height: 35)
.padding(10)
.background(Color(red: 238/255, green: 238/255, blue: 239/255))
}
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity)
.background(Color(red: 255/255, green: 254/255, blue: 185/255))
.padding(.horizontal)
}
}
struct SkyBlueView: View {
@Binding var weightedPriceUsagePercent: String
@Binding var wacSubWACUnitPricePercent: String
@Binding var pvpUnitPricePercent: String
@Binding var totalPercent: String
var body: some View {
HStack {
Group {
Text("Usage %")
HStack {
Spacer().frame(width: 10)
TextField("Percentage", text: $weightedPriceUsagePercent)
.textFieldStyle(.plain)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Rectangle().fill(Color.white))
.frame(height: 35)
HStack {
Spacer().frame(width: 10)
TextField("Percentage", text: $wacSubWACUnitPricePercent)
.textFieldStyle(.plain)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Rectangle().fill(Color.white))
.frame(height: 35)
.padding(10)
HStack {
Spacer().frame(width: 10)
TextField("Percentage", text: $pvpUnitPricePercent)
.textFieldStyle(.plain)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Rectangle().fill(Color.white))
.frame(height: 35)
ZStack {
HStack {
Spacer().frame(width: 10)
TextField("", text: $totalPercent)
.textFieldStyle(.plain)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Rectangle().fill(Color.white))
.frame(height: 35)
.border((Color(red: 183/255, green: 181/255, blue: 182/255)), width: 2)
.padding(10)
}
.frame(maxWidth: .infinity)
.frame(height: 35)
.padding(10)
.background(Color(red: 238/255, green: 238/255, blue: 239/255))
}
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity)
.background(Color(red: 199/255, green: 229/255, blue: 243/255))
.padding(.horizontal)
}
}
struct GreenView: View {
@Binding var weightedPriceCalculated: String
@Binding var wacSubWACUnitPriceCalculated: String
@Binding var pvpUnitPriceCalculated: String
@Binding var totalCalculated: String
var body: some View {
HStack {
Group {
Text("Weighted Price")
.frame(maxWidth: .infinity)
Text(weightedPriceCalculated)
.frame(maxWidth: .infinity)
Text(wacSubWACUnitPriceCalculated)
.frame(maxWidth: .infinity)
Text(pvpUnitPriceCalculated)
.frame(maxWidth: .infinity)
ZStack {
Text(totalCalculated)
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity)
.frame(height: 25)
.padding(10)
.background(Color(red: 76/255, green: 174/255, blue: 234/255))
}
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity)
.background(Color(red: 195/255, green: 231/255, blue: 145/255))
.padding(.horizontal)
}
}
final class LandingViewModel: ObservableObject {
@Published var gridDataArray: [LandingViewDataModel] = []
init(gridDataArray: [LandingViewDataModel]) {
self.gridDataArray = gridDataArray
self.gridDataArray.append(LandingViewDataModel(productLabel: "Endo Product",
productName: "Endo product something",
productId: "Endo product ID",
weightedProductPrice: "122.43",
weightedPriceUsagePercent: "30",
weightedPriceCalculated: "",
wacSubWACUnitPrice: "111.33",
wacSubWACUnitPricePercent: "20",
wacSubWACUnitPriceCalculated: "",
pvpUnitPrice: "222.33",
pvpUnitPricePercent: "50",
pvpUnitPriceCalculated: "",
totalPercent: "",
totalCalculated: ""))
self.gridDataArray.append(LandingViewDataModel(productLabel: "Competitor 1",
productName: "Competitor 1",
productId: nil,
weightedProductPrice: "198.43",
weightedPriceUsagePercent: "10",
weightedPriceCalculated: "",
wacSubWACUnitPrice: "987.33",
wacSubWACUnitPricePercent: "70",
wacSubWACUnitPriceCalculated: "",
pvpUnitPrice: "876.33",
pvpUnitPricePercent: "20",
pvpUnitPriceCalculated: "",
totalPercent: "",
totalCalculated: ""))
self.gridDataArray.append(LandingViewDataModel(productLabel: "Competitor 2",
productName: "Competitor 2",
productId: nil,
weightedProductPrice: "445.43",
weightedPriceUsagePercent: "40",
weightedPriceCalculated: "",
wacSubWACUnitPrice: "432.33",
wacSubWACUnitPricePercent: "20",
wacSubWACUnitPriceCalculated: "",
pvpUnitPrice: "456.33",
pvpUnitPricePercent: "40",
pvpUnitPriceCalculated: "",
totalPercent: "",
totalCalculated: ""))
}
}
struct LandingViewDataModel: Hashable, Equatable {
let productLabel: String
var productName: String
var productId: String?
var weightedProductPrice: String {
didSet {
calculate()
}
}
var weightedPriceUsagePercent: String {
didSet {
calculate()
}
}
var weightedPriceCalculated: String
var wacSubWACUnitPrice: String {
didSet {
calculate()
}
}
var wacSubWACUnitPricePercent: String {
didSet {
calculate()
}
}
var wacSubWACUnitPriceCalculated: String
var pvpUnitPrice: String {
didSet {
calculate()
}
}
var pvpUnitPricePercent: String {
didSet {
calculate()
}
}
var pvpUnitPriceCalculated: String
var totalPercent: String
var totalCalculated: String
}
extension LandingViewDataModel {
private mutating func calculate() {
if let flotPrice = Float(weightedProductPrice), let percentage = Float(weightedPriceUsagePercent) {
weightedPriceCalculated = "\(flotPrice * (percentage / 100))"
}
if let flotPrice = Float(wacSubWACUnitPrice), let percentage = Float(wacSubWACUnitPricePercent) {
wacSubWACUnitPriceCalculated = "\(flotPrice * (percentage / 100))"
}
if let flotPrice = Float(pvpUnitPrice), let percentage = Float(pvpUnitPricePercent) {
pvpUnitPriceCalculated = "\(flotPrice * (percentage / 100))"
}
if let weightedPriceUsagePercent = Int(weightedPriceUsagePercent),
let wacSubWACUnitPricePercent = Int(wacSubWACUnitPricePercent),
let pvpUnitPricePercent = Int(pvpUnitPricePercent) {
totalPercent = "\(weightedPriceUsagePercent + wacSubWACUnitPricePercent + pvpUnitPricePercent)"
}
if let weightedPriceCalculated = Float(weightedPriceCalculated),
let wacSubWACUnitPriceCalculated = Float(wacSubWACUnitPriceCalculated),
let pvpUnitPriceCalculated = Float(pvpUnitPriceCalculated) {
totalCalculated = "\(weightedPriceCalculated + wacSubWACUnitPriceCalculated + pvpUnitPriceCalculated)"
}
}
}
In order to solve this issue, you have to make quite a bit of changes to your original design.
Ensure that when using ForEach
, List
, etc., the element type (aka LandingViewDataModel
in the passed random access container conforms to Identifiable
.
Avoid mutating the view state (aka LandingViewDataModel
) directly - i.e. your mutating function calculate()
. Instead move the logic into the ObservableObject (aka LandingViewModel
).
The view should be a "function of state". Triple this.
A view should never change its view state directly in the "source of truth", aka your view model (means, don't use two-way bindings).
From #2, #3 and #4 follows: when designing SwiftUI views, avoid using two-way bindings unless absolutely necessary. Instead use a combination "let values" to define the view state in addition to closure variables which define the "commands" (intents) which can be sent from the view to the ObservableObject which handles these commands. So, instead of passing down a Binding
, pass down the const value (which represents the "view's state") and closures which get called when a user-initiated event happens (i.e. changing the text of an EditField, tapping a button, etc.).
In order to let SwiftUI to perform optimal diffing, avoid using functions which return a View
from within the body. Instead use a SwiftUI view. The best practice is to make many smaller views, instead of few large views.
Tip:
Collect and list all events that can happen in your whole view. Then, create an enum:
typealias ID = LandingViewDataModel.ID
enum Event {
case textFieldXyzChanged(id: ID, text: String)
case focusChanged(id: ID)
case submitButtonTapped(id: ID, payload: Data)
...
}
(note: since your events are associated to a certain element in the view model's array, you need to send the ID of the element with the event, so that the view model knows, which element is it associated with)
Then, you only need one method in the ObservableObject:
send(_ event: Event)
which is able to handle all events that can happen in this use case. That way, you need to pass down only one single closure function down to your views and subviews.