SwiftUI TextField Lag - swift

When I have multiple text fields rendered in SwiftUI in a given view, I am getting noticeable lag that is directly proportional to the number of text fields. If I change these to simple text views, the lag goes down considerably.
I have looked at SO and found a few questions about lag with TextField but generally it seems like there's a preponderance that the lag is caused by the data source because when using a constant value, the lag is not observed.
I have created a demo project to illustrate the issue. I have an array of 20 contact names and for each name create a contact card with three email addresses. If I toggle the view between rendering the email addresses as Text vs TextField Views (with a constant value), the time taken from button tap to the last view's .onAppear is 80-100 ms (Text) and 300-320 ms (TextField).
Both views take a noticeable time to render, but clearly the TextFields take a significantly longer time to render on this contrived, trivial app. In our app, we are rendering significantly more information and not using constant values for the TextFields so this lag produces more pronounced effects (sometimes a few seconds). Is there some way around this issue for SwiftUI TextFields? Below is the code for the demo project. I know there are better ways to write the code, just threw it together quickly to demonstrate the speed issues.
Also, interestingly, if I put the ForEach into a List (or just try to use a list directly from the array data), no ContactCard views are rendered at all.
Any help is greatly appreciated!
import SwiftUI
var formatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS"
return formatter
}
struct ContentView: View {
let contacts: Array<(first: String, last: String)> = [
("John", "Stone"),
("Ponnappa", "Priya"),
("Mia", "Wong"),
("Peter", "Stanbridge"),
("Natalie", "Lee-Walsh"),
("Ang", "Li"),
("Nguta", "Ithya"),
("Tamzyn", "French"),
("Salome", "Simoes"),
("Trevor", "Virtue"),
("Tarryn", "Campbell-Gillies"),
("Eugenia", "Anders"),
("Andrew", "Kazantzis"),
("Verona", "Blair"),
("Jane", "Meldrum"),
(" Maureen", "M. Smith"),
("Desiree", "Burch"),
("Daly", "Harry"),
("Hayman", "Andrews"),
("Ruveni", "Ellawala")
]
#State var isTextField = false
var body: some View {
ScrollView {
VStack {
HStack {
Button("Text") {
print("text tapped: \(formatter.string(from: Date()))")
isTextField = false
}
Button("TextField") {
print("text tapped: \(formatter.string(from: Date()))")
isTextField = true
}
}
ForEach(contacts, id: \.self.last) { contact in
ContactCard(name: contact, isTextField: $isTextField)
}
}
}
}
}
struct ContactCard: View {
var name: (first: String, last: String)
#Binding var isTextField: Bool
var emailAddresses: Array<String> {
[
"\(name.first).\(name.last)#home.com",
"\(name.first).\(name.last)#work.com",
"\(name.first).\(name.last)#work.org",
]
}
var body: some View {
VStack {
Text("\(name.first) \(name.last)")
.font(.headline)
ForEach(emailAddresses, id: \.self) { email in
HStack {
Text("Email")
.frame(width: 100)
if isTextField {
TextField("", text: .constant(email))
.onAppear(){
print("view appeared: \(formatter.string(from: Date()))")
}
} else {
Text(email)
.onAppear(){
print("view appeared: \(formatter.string(from: Date()))")
}
}
Spacer()
}
.font(.body)
}
}
.padding()
}
}

Use LazyVStack in your scroll view instead of VStack. It worked for me, tested using 200 contact names.

Related

How to implement infinite swipable week view in SwiftUI?

I am currently working on an app with class schedule. I need to implement a swipable week view just like in Apple's "Calendar" app. There is no built-in solution for this in SwiftUI (Maybe I didn't find it), so I used SwiftUIPager library. It kinda works, but I have to provide it an array of elements. Then Pager use this elements to create pages. This does not suit me, so I decided to dynamically add elements of the array when I approach its border.
struct WeekObserverView: View {
let vm: WeekObserverViewModel = WeekObserverViewModel()
let OnDayChanged: (Date)->Any
#State private var selectedDay = Date()
#State private var currentPage = Page.withIndex(2)
#State private var data = Array(-2..<3)
#State private var disableButtons = false
var body: some View {
VStack(spacing: 0){
VStack(alignment: .center, spacing: 0){
Pager(page: currentPage,
data: data,
id: \.self) {
self.generateWeekView($0)
}
.singlePagination(ratio: 0.5, sensitivity: .high)
.onPageWillChange({ (page) in
withAnimation {
//Moving forward or backward a week
selectedDay = selectedDay + TimeInterval(604800) * Double((page - currentPage.index))
}
_ = OnDayChanged(selectedDay)
})
.onPageChanged({
page in
//Adding new weeks when we approach the boundaries of the array
if page == 1 {
let newData = (1...5).map { data.first! - $0 }.reversed()
withAnimation {
currentPage.update(.move(increment: newData.count))
data.insert(contentsOf: newData, at: 0)
}
} else if page == self.data.count - 2 {
guard let last = self.data.last else { return }
let newData = (1...5).map { last + $0 }
withAnimation {
data.append(contentsOf: newData)
}
}
disableButtons = false
})
.onDraggingBegan({
disableButtons = true
})
.pagingPriority(.simultaneous)
.frame(height: 48)
Capsule()
.frame(width: 32, height: 6)
.foregroundColor(Color("TransparetPurple"))
.padding(4)
}
.frame(maxWidth: .infinity)
.background(Color("AccentBlue"))
//Spacer()
}
}
The problem is when I adding element to the front of array Pager loses current page and goes to the next page on the left.
I tried to add one extra page to the index, but it didn't work
currentPage.update(.move(increment: newData.count + 1))
Maybe there is a more simple solution without providing an array? I want Pager to just give me the offset from first loaded page.
I looked into this and checked an example called InfiniteExampleView included in the git project for SwiftUIPager.
I noticed how they are able to add items to the array dynamically both in front of the current items and behind them.
I can't test your code completely since there are some missing parts to make it run the way you run it, but it seems when they insert in front of current items they update the page index as well and I don't see you do that in your code.
This is the code from the example:
if page == 1 {
let newData = (1...5).map { data1.first! - $0 }.reversed()
withAnimation {
page1.index += newData.count //<-- this line
data1.insert(contentsOf: newData, at: 0)
isPresented.toggle()
}
}
this is your code under the withAnimation part of if statement page==1:
currentPage.update(.move(increment: newData.count))
data.insert(contentsOf: newData, at: 0)
So maybe adding a line to change the index helps fix this. If not, please provide a more complete example to make it possible to debug it more easily.

Provide default value to properties in SwiftUI

I've learning SwiftUI for a week, recently I found a confusing issue with it.
#State private var checkAmount = 0.0
var body: some View {
NavigationView {
Form {
Section {
TextField("Amount", value: $checkAmount, format: .currency(code: Locale.current.currency?.identifier ?? "USD"))
.keyboardType(.decimalPad)
}
Section{
Text(checkAmount, format:.currency(code: Locale.current.currency?.identifier ?? "USD"))
}
}
}
}
I use TextFile to receive user's input and alter the value of checkAmount, and make the value shown in the section below. Here is my preview in xcode.
Preview
But when I type a random number and delete it all, this happend:
Still a digit here
It seems SwiftUI didn't delete it all, and still keep the last digit I deleted.
I guess maybe I should give it an default value when user's input is empty?
Some additional information maybe necessary: I'm using a M1 MacMini and XCode 14.1
Approach
For currency(code:) the value type is Decimal, so you have to use Optional Decimal
https://developer.apple.com/documentation/foundation/parseableformatstyle/3796617-currency
When the last digit is deleted it becomes nil
Use checkAmount ?? 0 in your TextField
Code
struct ContentView: View {
#State private var checkAmount = Decimal?(0.0) //Optional decimal
var body: some View {
NavigationView {
Form {
Section {
TextField("Amount", value: $checkAmount, format: .currency(code: Locale.current.currency?.identifier ?? "USD"))
.keyboardType(.decimalPad)
}
Section{
//Use ?? operator to display 0 when nil
Text(checkAmount ?? 0, format:.currency(code: Locale.current.currency?.identifier ?? "USD"))
}
}
.onChange(of: checkAmount) { newValue in
print("checkAmount = \(String(describing: newValue))")
}
}
}
}

SwiftUI Picker Help -- Populating picker based on the selection of another picker

I am trying to populate a picker based on the selection of another picker. I am new to Swift and have been beating my head on this for way too long. I am sure its not as difficult as I am making it but I would appreciate any assistance.
I think my biggest issue is passing the selection of the first picker to the array name of the second. I have used switch case, tried to pass the selection raw value...etc. Below is a sample of what I would like it to look like without the binding of the pickers. Thanks
import SwiftUI
struct veggie: View {
let veggies = ["Beans", "Corn", "Potatoes"]
let beanList = ["Pole", "String", "Black"]
let cornList = ["Peaches & Cream", "Sweet"]
let potatoList = ["Yukon Gold", "Idaho"]
#State private var selectedVeggie = "Bean"
#State private var selectedBean = "Pole"
#State private var selectedType = ""
var body: some View {
NavigationView{
VStack{
Form{
Picker("Please choose a veggie", selection: $selectedVeggie)
{
ForEach(veggies, id: \.self) {
Text($0)
}
}
Text("You selected \(selectedVeggie)")
Picker("Type", selection: $selectedBean)
{
ForEach(beanList, id: \.self) {
Text($0)
}
}
}
} .navigationTitle("Veggie Picker")
}
}
}
I'm imagining that you want this to be somewhat dynamic and not hardcoded with if statements. In order to accomplish that, I setup an enum with the veggie types and then a dictionary that maps the veggie types to their subtypes. Those look like:
enum VeggieType : String, CaseIterable {
case beans, corn, potatoes
}
let subTypes : [VeggieType: [String]] = [.beans: ["Pole", "String", "Black"],
.corn: ["Peaches & Cream", "Sweet"],
.potatoes: ["Yukon Gold", "Idaho"]]
Then, in the view, I did this:
struct ContentView: View {
#State private var selectedVeggie : VeggieType = .beans
#State private var selectedSubtype : String?
var subtypeList : [String] {
subTypes[selectedVeggie] ?? []
}
var body: some View {
NavigationView{
VStack{
Form{
Picker("Please choose a veggie", selection: $selectedVeggie)
{
ForEach(VeggieType.allCases, id: \.self) {
Text($0.rawValue.capitalized)
}
}
Text("You selected \(selectedVeggie.rawValue.capitalized)")
Picker("Type", selection: $selectedSubtype)
{
ForEach(subtypeList, id: \.self) {
Text($0).tag($0 as String?)
}
}
}
} .navigationTitle("Veggie Picker")
}
}
}
The selectedVeggie is now typed with VeggieType. The selectedSubtype is an optional String that doesn't have an initial value (although you could set one if you want).
The first picker goes through all of the cases of VeggieType. The second one dynamically changes based on the computed property subtypeList which grabs the item from the subTypes dictionary I had made earlier.
The tag item is important -- I had to make the tag as String? because SwiftUI wants the selection parameter and the tag() to match exactly.
Note: you say you're new to Swift, so I'll mention that you had named your view `veggie` -- in Swift, normally types have capital names. I named mine `ContentView`, but you could rename it `VeggieView` or something like that.

How to build NumberField in SwiftUI?

I'm working on MacOS and I try to make NumberField — something like TextField, but for numbers. In rather big tree of views at the top I had:
...
VStack {
ForEach(instances.indices, id:\.self) {index in
TextField("",
text: Binding(
get: {"\(String(format: "%.1f", instances[index].values[valueIndex]))"},
set: {setValueForInstance(index, valueIndex, $0)})
)
}
}
...
And it worked well, but not nice:
✔︎ when I changed value, all View structure was redrawn – good
✔︎ values was updated if they were changed by another part of Views structure – good
✖︎ it was updated after each keypresses, which was annoying, when I tried to input 1.2, just after pressing 1 view was updated to 1.0. Possible to input every number but inconvenient – bad
So, I tried to build NumberField.
var format = "%.1f"
struct NumberField : View {
#Binding var number: Double {
didSet {
stringNumber = String(format: format, number)
}
}
#State var stringNumber: String
var body: some View {
TextField("" , text: $stringNumber, onCommit: {
print ("Commiting")
if let v = Double(stringNumber) {
number = v
} else {
stringNumber = String(format:format, number)
}
print ("\(stringNumber) , \(number)" )
})
}
init (number: Binding<Double>) {
self._number = number
self._stringNumber = State(wrappedValue: String(format:format, number.wrappedValue))
}
}
And It's called from the same place as before:
...
VStack {
ForEach(instances.indices, id:\.self) {index in
NumberField($instances[index].values[valueIndex])
}
}
...
But in this case it never updates NumberField View if values was changed by another part of View. Whats's wrong? Where is a trick?

DispatchQueue triggering foreach loop twice with SwiftUI

I'm having a weird behavior with my code.
There is View with text that is getting its text values from the User object.
If I take the reference out and leave it with just String everything is good, but with it the foreach loop that is getting this value runs twice.
Also, if I put the assignment out from DispatchQueue it runs once but the view isn't updating itself (because its a #Published var).
Even if I remove #Published it still run twice.
I'm sure there is a simple explanation for this behavior but I really don't know what could make this happen.
Better explanation in code with comments:
The view:
struct MenuGroupBoxList: View {
#EnvironmentObject var session: SessionStore
var columns = [
GridItem(.adaptive(minimum: 100, maximum: 200), spacing: 8),
GridItem(.adaptive(minimum: 100, maximum: 200), spacing: 8)
]
var body: some View {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(MenuTitles.allCases, id:\.self) { item in
if item == .sentShifts{
GroupColumn(menuItem: item, unit: Text(session.user?.totalShiftConsts.description ?? "0"))
}else if item == .weekdays{
//Deleting this line: session.user?.totalShifts.description. will run the loop once.
GroupColumn(menuItem: item, unit: Text(session.user?.totalShifts.description ?? "0"))
}else{
GroupColumn(menuItem: item, unit: Text("0"))
}
}
}.background(Color(UIColor.systemGroupedBackground))
}
}
Where the foreach loop:
enum ChooseWeekdays{
case current, next
}
class WeekdaysModel: Identifiable, ObservableObject{
private var session = SessionStore.shared
private var date = Date()
#Published var week = [Day]()
#Published var isFetching = false
init(_ weekdays: ChooseWeekdays){
switch weekdays {
case .current:
currentWeekdaysFetch()
case .next:
nextWeekdaysFetch()
}
}
private func currentWeekdaysFetch(){
DispatchQueue.main.async {
self.isFetching = true
var count = 0
for (index, symbol) in self.date.weekDaySymbols.enumerated(){
let day = Day(date: self.date.currentWeek[index], isSelected: false, hasShifts: false, daySymbol: symbol, dayNumber: self.date.currentWeekNumbers[index])
day.setShifts(morning: self.session.employeesList, middle: self.session.employeesList, evening: self.session.employeesList)
if index == Date().getCurrentDayIndex(){
day.isSelected = true
}
count = count + day.shifts.compactMap({$0.employees.contains(where: {$0.uid == self.session.user?.uid})}).count
self.week.append(day)
self.isFetching = false
//Printing to see if loop run twice.
print("Index:", index)
}
//OR - take this line out from DispatchQueue, will run once but wouldn't update the UI because totalShifts is a #Published var
self.session.user?.totalShifts = count
}
}
}
The results I'm getting in print is Index: 0...6 twice.
Where WeekdaysModel created:
struct WeekdaysView: View {
#ObservedObject var viewModel = WeekdaysModel(.current)
var body: some View {
ScrollView{
WeekRow(model: viewModel)
ForEach(viewModel.week){ day in
ShiftTypesView(day: day)
}
}
.navigationTitle(LocalizedStringKey("Weekdays"))
.background(Color(UIColor.systemGroupedBackground))
.edgesIgnoringSafeArea(.bottom)
}
}
There is another initiation in a different file but different enum (WeekdaysModel(.next))
seems a common case on SwiftUI.
I did experiment similar behaviour filling cells of a tableview being called twice for every cell: it seems swiftUI fills every viewed elements twice (I assume to satisfy rotation)
As suggested fill a data structure ONCE out of swiftUI "building of views"
(your data will be read anyway twice, but no double expensive calls to DB or network)
This behaviour is very different from std UiKit and can be frustrating if network is involved (calls will be issued twice... double network completions, errors on servers (for example double login and so on...)