How to animate switching from one Text to another with swift ui? - swiftui

I have an array of strings. For example ["Car", "Boat", "Van"]
How do I anime the changing of the text in the array (which can contain more strings) so that it switches from Car to Boat to Van by blurring transition? And so that it continually loops this?
I already have an idea on how to animate, but I was stuck in switching the text.
I have asked the question on why the text does not switch over here -> Why does the size animate and not the text with this SwiftUI view?
But I thought It might be better to write a separate question on how to actually switch the text.

Here is a possible solution animating text based on an array. I have used Asperis transition idea from this solution here
struct ContentView: View {
var array = ["First", "Second", "Third"]
#State var shortString = true
#State var currentIndex : Int = 0
#State var firstString : String = ""
#State var secondString : String = ""
var body: some View {
VStack {
if shortString {
Text(firstString).font(.title).fixedSize()
.transition(AnyTransition.opacity.animation(.easeInOut(duration:1.0)))
}
if !shortString {
Text(secondString).font(.title).fixedSize()
.transition(AnyTransition.opacity.animation(.easeInOut(duration:1.0)))
}
}
.animation(.default)
.onAppear {
firstString = array[0]
secondString = array[1]
let timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in
if (shortString) {
if currentIndex == array.count - 1 {
self.secondString = array[0]
currentIndex = 0
}
else {
self.secondString = array[currentIndex+1]
currentIndex += 1
}
}
else {
if currentIndex == array.count - 1 {
self.firstString = array[0]
currentIndex = 0
}
else {
self.firstString = array[currentIndex+1]
currentIndex += 1
}
}
shortString.toggle()
}
}
}
}

I have already selected #davidev answer. But based on his answer this was what I have implemented. Cheers 🍺
struct ContentView: View {
var array = ["First", "Second", "Third"]
#State var currentIndex : Int = 0
#State var firstString : String = ""
#State var timer: Timer? = nil
#State var isBlurred = false
var body: some View {
VStack {
Text(firstString).blur(radius: isBlurred ? 6 : 0)
}.onAppear {
self.timer = newTimer
}
}
var newTimer: Timer {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { v in
let rndTime = [0.5, 0.3, 0.7, 1.0].randomElement()! // I wanted a random time up to 1 second.
v.invalidate()
currentIndex += 1
if currentIndex == array.count { currentIndex = 0 }
DispatchQueue.main.asyncAfter(deadline: .now() + rndTime) {
self.isBlurred.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.isBlurred.toggle()
firstString = array[currentIndex]
self.timer = newTimer
}
}
}
}
}

Related

How can I make my subview with a binding to mainview disappear and cleanly reappear?

What I expect to happen
I have a meditation view that has an animation subview with a binding property inhaling that should appear when a button is pressed.
When the animation subview appears, it should start the animation from the beginning. It's the Apple meditation breathing animation basically: it starts as a small ball and gets bigger as inhaling is true, and then smaller as inhaling is false.
When the user presses the button again, the animation should disappear.
When the user then again presses the button, a second time, it should start the animation subview with a binding clean. Meaning the subview is a small ball and gets big again. Like the first time.
struct Meditation: View {
#State var startBreathingAnimation = false
#State var inhaling = false
#State var infoText = "Start a mediation"
var body: some View {
VStack(spacing: 20) {
ZStack {
if startBreathingAnimation {
BreathAnimation(inhaling: $inhaling)
.onChange(of: inhaling) { newValue in
if newValue {
infoText = "Breath in..."
} else {
infoText = "Breath out..."
} }
.onDisappear {
infoText = "Start your meditation" // Never executed?
}
} else {
Circle()
.frame(height: 100)
.foregroundColor(.blue)
}
}
Text(infoText)
Button("Toggle") {
startBreathingAnimation.toggle()
}
}
.padding()
}
}
What actually happens
The animation subview with a binding is not reset, newly initialized, but starts just where it left off after being "dismissed" with the button press.
When I don't add a binding property into the subview, it actually works as expected: it resets every time and gives me a "fresh" subview. But I do actually need to observe changes to the animation subview property inhaling in order to update the infoText property in the main view.
Reproducible example code, ready to copy into Xcode
Any help is greatly appreciated!
// Can be copied to Xcode directly
struct Meditation: View {
#State var startBreathingAnimation = false
#State var inhaling = false
#State var infoText = "Start a mediation"
var body: some View {
VStack(spacing: 20) {
ZStack {
if startBreathingAnimation {
BreathAnimation(inhaling: $inhaling)
.onChange(of: inhaling) { newValue in
if newValue {
infoText = "Breath in..."
} else {
infoText = "Breath out..."
} }
.onDisappear {
infoText = "Start your meditation" // Never executed?
}
} else {
Circle()
.frame(height: 100)
.foregroundColor(.blue)
}
}
Text(infoText)
Button("Toggle") {
startBreathingAnimation.toggle()
}
}
.padding()
}
}
private let gradientStart = Color.accentColor.opacity(0.9)
private let gradientEnd = Color.accentColor.opacity(1.0)
private let gradient = LinearGradient(gradient: Gradient(colors: [gradientStart, gradientEnd]), startPoint: .top, endPoint: .bottom)
private let maskGradient = LinearGradient(gradient: Gradient(colors: [.black]), startPoint: .top, endPoint: .bottom)
private let maxSize: CGFloat = 150
private let minSize: CGFloat = 30
private let inhaleTime: Double = 8
private let exhaleTime: Double = 8
private let pauseTime: Double = 1.5
private let numberOfPetals = 4
private let bigAngle = 360 / numberOfPetals
private let smallAngle = bigAngle / 2
private let ghostMaxSize: CGFloat = maxSize * 0.99
private let ghostMinSize: CGFloat = maxSize * 0.95
private struct Petals: View {
let size: CGFloat
let inhaling: Bool
var isMask = false
var body: some View {
let petalsGradient = isMask ? maskGradient : gradient
ZStack {
ForEach(0..<numberOfPetals) { index in
petalsGradient
.frame(maxWidth: .infinity, maxHeight: .infinity)
.mask(
Circle()
.frame(width: size, height: size)
.offset(x: inhaling ? size * 0.5 : 0)
.rotationEffect(.degrees(Double(bigAngle * index)))
)
.blendMode(isMask ? .normal : .screen)
}
}
}
}
struct BreathAnimation: View {
#State private var size = minSize
#Binding var inhaling: Bool
#State private var ghostSize = ghostMaxSize
#State private var ghostBlur: CGFloat = 0
#State private var ghostOpacity: Double = 0
var body: some View {
ZStack {
// Color.black
// .edgesIgnoringSafeArea(.all)
ZStack {
// ghosting for exhaling
Petals(size: ghostSize, inhaling: inhaling)
.blur(radius: ghostBlur)
.opacity(ghostOpacity)
// the mask is important, otherwise there is a color
// 'jump' when exhaling
Petals(size: size, inhaling: inhaling, isMask: true)
// overlapping petals
Petals(size: size, inhaling: inhaling)
Petals(size: size, inhaling: inhaling)
.rotationEffect(.degrees(Double(smallAngle)))
.opacity(inhaling ? 0.8 : 0.6)
}
.rotationEffect(.degrees(Double(inhaling ? bigAngle : -smallAngle)))
.drawingGroup()
}
.onAppear {
performAnimations()
}
.onDisappear {
size = minSize
inhaling = false
ghostSize = ghostMaxSize
ghostBlur = 0
ghostOpacity = 0
}
}
func performAnimations() {
withAnimation(.easeInOut(duration: inhaleTime)) {
inhaling = true
size = maxSize
}
Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime, repeats: false) { _ in
ghostSize = ghostMaxSize
ghostBlur = 0
ghostOpacity = 0.8
Timer.scheduledTimer(withTimeInterval: exhaleTime * 0.2, repeats: false) { _ in
withAnimation(.easeOut(duration: exhaleTime * 0.6)) {
ghostBlur = 30
ghostOpacity = 0
}
}
withAnimation(.easeInOut(duration: exhaleTime)) {
inhaling = false
size = minSize
ghostSize = ghostMinSize
}
}
Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime + exhaleTime + pauseTime, repeats: false) { _ in
// endless animation!
performAnimations()
}
}
private func performAnimations2() {
withAnimation(.easeInOut(duration: inhaleTime)) {
inhaling = true
size = maxSize
}
Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime, repeats: false) { _ in
ghostSize = ghostMaxSize
ghostBlur = 0
ghostOpacity = 0.8
Timer.scheduledTimer(withTimeInterval: exhaleTime * 0.2, repeats: false) { _ in
withAnimation(.easeOut(duration: exhaleTime * 0.6)) {
ghostBlur = 30
ghostOpacity = 0
}
}
withAnimation(.easeInOut(duration: exhaleTime)) {
inhaling = false
size = minSize
ghostSize = ghostMinSize
}
}
Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime + exhaleTime + pauseTime, repeats: false) { _ in
// endless animation!
performAnimations()
}
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
Meditation()
}
}
Here is a possible approach by setting a specific .id for the view and changing it on reset, forcing a redraw of the subview:
struct ContentView: View {
#State var startBreathingAnimation = false
#State var inhaling = false
#State var infoText = "Start a mediation"
#State var viewID = UUID()
var body: some View {
VStack(spacing: 20) {
ZStack {
if startBreathingAnimation {
BreathAnimation(inhaling: $inhaling)
.id(viewID) // here
.onChange(of: inhaling) { newValue in
if newValue {
infoText = "Breath in..."
} else {
infoText = "Breath out..."
}
}
} else {
Circle()
.frame(height: 100)
.foregroundColor(.blue)
}
}
Text(infoText)
Button("Toggle") {
if startBreathingAnimation {
startBreathingAnimation = false
infoText = "Start your meditation"
inhaling = false
} else {
startBreathingAnimation = true
viewID = UUID()
}
}
}
.padding()
}
}
As an extension to ChrisR's answer, which helped give me a fresh subview, but created the problem of out-of-sync animation property values, I used the help of PreferenceKeys. PreferenceKeys are apparently not that known among many intermediate SwiftUI devs, so I thought I'd share it here briefly.
Swiftful Thinking has a great video on them: link to video
A binding to a subview and its parent creates a way to strong connection for my case. I only want to observe the inhaling property of BreathAnimation() on my MainView().
That's when PreferenceKeys come into play.
Here is the code that helped me solve my issue.
Create a property that can be accessed from all views if needed:
struct InhalingPreferenceKey: PreferenceKey {
static var defaultValue: Bool = false
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = nextValue()
}
}
// Housekeeping that lets us update the preference key in our childview
extension View {
func updateInhalingPreferenceKey(_ isInhaling: Bool) -> some View {
preference(key: InhalingPreferenceKey.self, value: isInhaling)
}
}
Add this to the childview and connect it to the BreathAnimation property inhaling:
var body: some View {
VStack {
// Content of child view
}
.updateInhalingPreferenceKey(inhaling)
}
And finally, we can access the childview property by using this:
.onPreferenceChange(InhalingPreferenceKey.self, perform: { inhaling in
self.inhaling = inhaling
}) // self.inhaling is the parentview property
This together with ChrisR's solution for fresh child views helped me achieve what I wanted. Hope this might help someone else as well!

SwiftUICharts are not redrawn when given new data

I am adding the possibility to swipe in order to update a barchart. What I want to show is statistics for different station. To view different station I want the user to be able to swipe between the stations. I can see that the swiping works and each time I swipe I get the correct data from my controller. The problem is that my view is not redrawn properly.
I found this guide, but cannot make it work.
Say I swipe right from station 0 with data [100, 100, 100] to station 2, the retrieved data from my controller is [0.0, 100.0, 0.0]. The view I have still is for [100, 100, 100]`.
The station number is correctly updated, so I suspect it needs some state somehow.
Here is the code:
import SwiftUI
import SwiftUICharts
struct DetailedResultsView: View {
#ObservedObject var viewModel: ViewModel = .init()
#State private var tabIndex: Int = 0
#State private var startPos: CGPoint = .zero
#State private var isSwiping = true
var body: some View {
VStack {
Text("Station \(viewModel.getStation() + 1)")
TabView(selection: $tabIndex) {
BarCharts(data: viewModel.getData(kLatestRounds: 10, station: viewModel.getStation()), disciplineName: viewModel.getName()).tabItem { Group {
Image(systemName: "chart.bar")
Text("Last 10 Sessions")
}}.tag(0)
}
}.gesture(DragGesture()
.onChanged { gesture in
if self.isSwiping {
self.startPos = gesture.location
self.isSwiping.toggle()
}
}
.onEnded { gesture in
if gesture.location.x - startPos.x > 10 {
viewModel.decrementStation()
}
if gesture.location.x - startPos.x < -10 {
viewModel.incrementStation()
}
}
)
}
}
struct BarCharts: View {
var data: [Double]
var title: String
init(data: [Double], disciplineName: String) {
self.data = data
title = disciplineName
print(data)
}
var body: some View {
VStack {
BarChartView(data: ChartData(points: self.data), title: self.title, style: Styles.barChartStyleOrangeLight, form: CGSize(width: 300, height: 400))
}
}
}
class ViewModel: ObservableObject {
#Published var station = 1
let controller = DetailedViewController()
var isPreview = false
func getData(kLatestRounds: Int, station: Int) -> [Double] {
if isPreview {
return [100.0, 100.0, 100.0]
} else {
let data = controller.getResults(kLatestRounds: kLatestRounds, station: station, fileName: userDataFile)
return data
}
}
func getName() -> String {
controller.getDiscipline().name
}
func getNumberOfStations() -> Int {
controller.getDiscipline().getNumberOfStations()
}
func getStation() -> Int {
station
}
func incrementStation() {
station = (station + 1) % getNumberOfStations()
}
func decrementStation() {
station -= 1
if station < 0 {
station = getNumberOfStations() - 1
}
}
}
The data is printed inside the constructor each time I swipe. Shouldn't that mean it should be updated?
I don’t use SwiftUICharts so I can’t test it, but the least you can try is manually set the id to the view
struct DetailedResultsView: View {
#ObservedObject var viewModel: ViewModel = .init()
#State private var tabIndex: Int = 0
#State private var startPos: CGPoint = .zero
#State private var isSwiping = true
var body: some View {
VStack {
Text("Station \(viewModel.getStation() + 1)")
TabView(selection: $tabIndex) {
BarCharts(data: viewModel.getData(kLatestRounds: 10, station: viewModel.getStation()), disciplineName: viewModel.getName())
.id(viewmodel.station) // here. If it doesn’t work, you can set it to the whole TabView
.tabItem { Group {
Image(systemName: "chart.bar")
Text("Last 10 Sessions")
}}.tag(0)
}
}.gesture(DragGesture()
.onChanged { gesture in
if self.isSwiping {
self.startPos = gesture.location
self.isSwiping.toggle()
}
}
.onEnded { gesture in
if gesture.location.x - startPos.x > 10 {
viewModel.decrementStation()
}
if gesture.location.x - startPos.x < -10 {
viewModel.incrementStation()
}
}
)
}
}

SwiftUI "Game over.Restart?" message pop-up

I am new to SwiftUI and I am stuck here.
I have a "choose right flag game". Currently it can show you 3 flags and you have to choose the one that represents country named above. It keeps score of your game and number of rounds you've played( max is 20 ).
When you hit the 20 game sets all numbers to 0 and you start again.
I want to make it pop-up alert message with "Game over. Your result is (bad, average, good ( based on the amount of scores))!" and button "Restart".
I've made alert massage for every round, but simply "copy-paste" with some changes doesn't work.
How can I do it?
import SwiftUI
struct ContentView: View {
#State private var countries = [
"afghanistan",
"albania",.......//here goes list of countries
"zimbabwe"
].shuffled()
#State private var correctAnaswer = Int.random(in: 0...2)
#State private var score = 0
#State private var showingAlert = false
#State private var endGameAlert = false
#State private var alertTitle = ""
#State private var currentRound = 0
#State private var maxRound = 20
var body: some View {
NavigationView {
VStack{
ForEach((0...2), id:\.self) { number in
Image(self.countries[number])
.border(Color.black, width: 1)
.onTapGesture {
self.flagTapped(number)
}
}
Text("Your Score \(score), current round is \(currentRound) of \(maxRound) ")
Spacer()
}
.background(Image("background").resizable().scaledToFill().edgesIgnoringSafeArea(.all).blur(radius: 20))
.navigationBarTitle(Text(countries[correctAnaswer].uppercased()))
.alert(isPresented: $showingAlert) {
Alert(title: Text(alertTitle),
message:Text("Your score is \(score)"),
dismissButton: .default(Text("Continue")){
self.askQuestion()
})
}
}
}
func flagTapped(_ tag: Int){
if currentRound <= 20 {
if tag == correctAnaswer {
score += 1
currentRound += 1
alertTitle = "Correct"
} else {
score -= 1
currentRound += 1
alertTitle = "Wrong"
}
showingAlert = true
}
else {
endGameAlert = true
score = 0
currentRound = 0
}
}
func askQuestion() {
countries.shuffle()
correctAnaswer = Int.random(in: 0...2)
}
}
First change your flagTapped(_) into something like this:
func flagTapped(_ tag: Int){
if tag == correctAnaswer {
score += 1
alertTitle = "Correct"
} else {
score -= 1
alertTitle = "Wrong"
}
endGameAlert = currentRound == maxRound
// score = 0 // reset this at the "Restart" call
// currentRound = 0 // reset this at the "Restart" call
if currentRound < maxRound {
currentRound += 1
}
showingAlert = true
}
Then you can check for endGameAlert in the .alert:
.alert(isPresented: $showingAlert) {
if endGameAlert {
return Alert(title: Text("Game over"),
message:Text("Your result is <>!"),
dismissButton: .default(Text("Restart")){
self.resetGame() // reset game here
})
} else {
return Alert(title: Text(alertTitle),
message:Text("Your score is \(score)"),
dismissButton: .default(Text("Continue")){
self.askQuestion()
})
}
}

Why does SwiftUI update function doesn't work?

There are 2 views (structs).
First view has a #state update:
struct SettingsView: View {
#State private var lang = 0
#State private var languages = ["English", "Spanish"]
#State private var text1 = "Close"
#State private var text2 = "Settings"
#State var show = false
#State var update = false
var body: some View {
ZStack{
Button(action: {
self.show.toggle()
}) {
Text("Choose language")
}
if self.$show.wrappedValue {
GeometryReader {proxy in
ChooseLanguage(show: self.$show, update: self.$update)
}.background(Color.black.opacity(0.65)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
withAnimation{
self.show.toggle()
}
})
}
}.onAppear{
switch UserDefaults.standard.string(forKey: "languageSettings"){
case "en": self.lang = 0
case "es": self.lang = 1
default: return
}
self.updateLanguage()
}
func updateLanguage(){
if self.lang == 1 {
self.text1 = "Cerrar"
self.text2 = "Configuración"
self.languages = ["Inglés", "Español"]
} else {
self.text1 = "Close"
self.text2 = "Settings"
self.languages = ["English", "Spanish"]
}
}
}
}
The second view has #Binding update:
import SwiftUI
struct ChooseLanguage : View {
var languages = UserDefaults.standard.stringArray(forKey: "langlist")
#Binding var show: Bool
#Binding var update: Bool
var body: some View {
ZStack {
VStack {
Button(action: {
UserDefaults.standard.set("en", forKey: "languageSettings")
UserDefaults.standard.set(["English", "Spanish"], forKey: "langlist")
self.show.toggle()
self.update = true
}) {
Text(languages![0])
}
Button(action: {
UserDefaults.standard.set("es", forKey: "languageSettings")
UserDefaults.standard.set(["Inglés", "Español"], forKey: "langlist")
self.show.toggle()
self.update = true
}) {
Text(languages![1])
}
}
}
}
}
When I call the func updateLanguage() before the .onAppear only errors appear.
Why I can update the values with function from the onAppear and I can't do this from the wrappedValue?
if self.$update.wrappedValue {
self.updateLanguage()
self.update.toggle()
}
This part doesn't work if to place before }.onAppear
As far as I see, you can make it so much easier with using init() method for your view.
There you can declare and initialize all your #State variables with the correct value (depending on your UserDefaults)
Just to show you an example:
struct SetView: View {
#State private var lang : Int
#State private var languages : [String]
#State private var text1 : String
#State private var text2 : String
#State var show = false
#State var update = false
init()
{
var state : Int = 0
switch UserDefaults.standard.string(forKey: "languageSettings")
{
case "en": state = 0
case "es": state = 1
default:
//Default value here
state = 0
}
if state == 1 {
self._lang = State(initialValue: state)
self._text1 = State(initialValue: "Cerrar")
self._text2 = State(initialValue: "Configuración")
self._languages = State(initialValue: ["Inglés", "Español"])
} else {
self._lang = State(initialValue: state)
self._text1 = State(initialValue: "Close")
self._text2 = State(initialValue: "Settings")
self._languages = State(initialValue: ["English", "Spanish"])
}
}
You won't need onAppear method at all, when you initialize your State variables with the correct value from the beginning.
I haven't tested it yet. Code is just out of my mind above.
Instead of calling the function after the #State upload value has been changed easier to send bindings for each text to the popup view.
GeometryReader {proxy in
ChooseLanguage(show: self.$show,
text1: self.$text1,
text2: self.$text2,
languages: self.$languages)
}

NumberField or how to make TextField input a Double, Float or other numbers with dot

According to comment in this question I made a custom SwifUI View based on a TextField. It use numeric keypad, you can't input there nothing but numbers and point, there can be only one point (dot), and you can pass a Bindable Double #State value through the View for input.
But there is a bug: when you deleting a last zero in "xxx.0" - zero still comes out. When you deleting a dot - zero becomes a part of integer, so it goes to "xxx0"
Any idea how to fix it? I tried to make value an integer when deleting last number before dot - but I can't catch the moment when there is only one last dot in a string.
here's full code:
import SwiftUI
import Combine
struct DecimalTextField: View {
public let placeHolder: String
#Binding var numericValue: Double
private class DecimalTextFieldViewModel: ObservableObject {
#Published var text = ""{
didSet{
DispatchQueue.main.async {
let substring = self.text.split(separator: Character("."), maxSplits: 2)
switch substring.count{
case 0:
if self.numericValue != 0{
self.numericValue = 0
}
case 1 :
var newValue: Double = 0
if let lastChar = substring[0].last{
if lastChar == Character("."){
newValue = Double(String(substring[0]).dropLast()) ?? 0
}else{
newValue = Double(String(substring[0])) ?? 0
}
}
self.numericValue = newValue
default:
self.numericValue = Double(String("\(String(substring[0])).\(String(substring[1]))")) ?? 0
}
}
}
}
private var subCancellable: AnyCancellable!
private var validCharSet = CharacterSet(charactersIn: "1234567890.")
#Binding private var numericValue: Double{
didSet{
DispatchQueue.main.async {
if String(self.numericValue) != self.text {
self.text = String(self.numericValue)
}
}
}
}
init(numericValue: Binding<Double>, text: String) {
self.text = text
self._numericValue = numericValue
subCancellable = $text.sink { val in
//check if the new string contains any invalid characters
if val.rangeOfCharacter(from: self.validCharSet.inverted) != nil {
//clean the string (do this on the main thread to avoid overlapping with the current ContentView update cycle)
DispatchQueue.main.async {
self.text = String(self.text.unicodeScalars.filter {
self.validCharSet.contains($0)
})
}
}
}
}
deinit {
subCancellable.cancel()
}
}
#ObservedObject private var viewModel: DecimalTextFieldViewModel
init(placeHolder: String = "", numericValue: Binding<Double>){
self._numericValue = numericValue
self.placeHolder = placeHolder
self.viewModel = DecimalTextFieldViewModel(numericValue: self._numericValue, text: numericValue.wrappedValue == Double.zero ? "" : String(numericValue.wrappedValue))
}
var body: some View {
TextField(placeHolder, text: $viewModel.text)
.keyboardType(.decimalPad)
}
}
struct testView: View{
#State var numeric: Double = 0
var body: some View{
return VStack(alignment: .center){
Text("input: \(String(numeric))")
DecimalTextField(placeHolder: "123", numericValue: $numeric)
}
}
}
struct decimalTextField_Previews: PreviewProvider {
static var previews: some View {
testView()
}
}
The only way to avoid loss of the decimal point and trailing 0's while typing is to keep track of the integral and fractional digits of the string across invocations of the numeric field, which means keeping the digit precision values as part of the state of the superview. See this gist for a fully functional (Swift 5) view that uses this technique. To see what happens if you don't keep the digit precision in the superview, compare the behavior of the first and second fields in the preview below: the first will handle typing as expected, the second will remove any trailing .0 as soon as the value changes.
I'm not sure if I really do everything right, but it looks like I fix that.
here's the code:
import SwiftUI
import Combine
fileprivate func getTextOn(double: Double) -> String{
let rounded = double - Double(Int(double)) == 0
var result = ""
if double != Double.zero{
result = rounded ? String(Int(double)) : String(double)
}
return result
}
struct DecimalTextField: View {
public let placeHolder: String
#Binding var numericValue: Double
private class DecimalTextFieldViewModel: ObservableObject {
#Published var text = ""{
didSet{
DispatchQueue.main.async {
let substring = self.text.split(separator: Character("."), maxSplits: 2)
if substring.count == 0{
if self.numericValue != 0{
self.numericValue = 0
}
}else if substring.count == 1{
var newValue: Double = 0
if let lastChar = substring[0].last{
let ch = String(lastChar)
if ch == "."{
newValue = Double(String(substring[0]).dropLast()) ?? 0
}else{
newValue = Double(String(substring[0])) ?? 0
}
}
if self.numericValue != newValue{
self.numericValue = newValue
}
}else{
let newValue = Double(String("\(String(substring[0])).\(String(substring[1]))")) ?? 0
if self.numericValue != newValue{
self.numericValue = newValue
}
}
}
}
}
private var subCancellable: AnyCancellable!
private var validCharSet = CharacterSet(charactersIn: "1234567890.")
#Binding private var numericValue: Double{
didSet{
DispatchQueue.main.async {
if String(self.numericValue) != self.text {
self.text = String(self.numericValue)
}
}
}
}
init(numericValue: Binding<Double>, text: String) {
self.text = text
self._numericValue = numericValue
subCancellable = $text.sink { val in
//check if the new string contains any invalid characters
if val.rangeOfCharacter(from: self.validCharSet.inverted) != nil {
//clean the string (do this on the main thread to avoid overlapping with the current ContentView update cycle)
DispatchQueue.main.async {
self.text = String(self.text.unicodeScalars.filter {
self.validCharSet.contains($0)
})
}
}
}
}
deinit {
subCancellable.cancel()
}
}
#ObservedObject private var viewModel: DecimalTextFieldViewModel
init(_ placeHolder: String = "", numericValue: Binding<Double>){
self._numericValue = numericValue
self.placeHolder = placeHolder
self.viewModel = DecimalTextFieldViewModel(numericValue: self._numericValue, text: getTextOn(double: numericValue.wrappedValue))
}
var body: some View {
TextField(placeHolder, text: $viewModel.text)
.keyboardType(.decimalPad)
}
}
struct testView: View{
#State var numeric: Double = 0
var body: some View{
return VStack(alignment: .center){
Text("input: \(String(numeric))")
DecimalTextField("123", numericValue: $numeric)
}
}
}
struct decimalTextField_Previews: PreviewProvider {
static var previews: some View {
testView()
}
}
But in debug I noticed the code in didSet executes several times. Not sure what is my mistake leading to that. Any suggestion?