SwiftUICharts are not redrawn when given new data - swiftui

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()
}
}
)
}
}

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!

Making data persist in Swift

I'm sorry if this is a naive question, but I need help getting this form to persist in core data. The variables are declared in the data model as strings. I simply cannot get this to cooperate with me. Also, the var wisconsin: String = "" is there because I can't call this view in my NavigationView without it throwing an error.
import SwiftUI
struct WisconsinToolOld: View {
//Variable
var wisconsin: String = ""
#Environment(\.managedObjectContext) private var viewContext
#State var saveInterval: Int = 5
var rateOptions = ["<12", ">12"]
#State var rate = ""
var body: some View {
List {
Section(header: Text("Spontaneous Respirations after 10 Minutes")) {
HStack {
Text("Respiratory Rate")
Spacer()
Picker("Rate", selection: $rate, content: {
ForEach(rateOptions, id: \.self, content: { rate in
Text(rate)
})
})
.pickerStyle(.segmented)
}
Section(header: Text("Result")) {
HStack {
Text("Raw Points")
Spacer()
Text("\(WisconsinToolInterpretation())")
}
}.navigationTitle("Wisconsin Tool")
}
}
func saveTool() {
do {
let wisconsin = Wisconsin(context: viewContext)
wisconsin.rate = rate
try viewContext.save()
} catch {
print(error.localizedDescription)
}
}
func WisconsinToolInterpretation() -> Int {
var points = 0
if rate == "<12" {
points += 3
}
else {
points += 1
}
return points
}
}

Why Timer Change Will Trigger LazyVGrid View Update?

import SwiftUI
struct ContentView: View {
#State private var set = Set<Int>()
#State private var count = "10"
private let columns:[GridItem] = Array(repeating: .init(.flexible()), count: 3)
#State private var timer:Timer? = nil
#State private var time = 0
var body: some View {
VStack {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(Array(set)) { num in
Text(String(num))
}
}
}
.frame(width: 400, height: 400, alignment: .center)
HStack{
TextField("Create \(count) items", text: $count)
Button {
createSet(count: Int(count)!)
} label: {
Text("Create")
}
}
if let _ = timer {
Text(String(time))
.font(.title2)
.foregroundColor(.green)
}
HStack {
Button {
time = 100
let timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in
time -= 10
if time == 0 {
self.timer?.invalidate()
self.timer = nil
}
}
self.timer = timer
} label: {
Text("Start Timer")
}
Button {
self.timer?.invalidate()
self.timer = nil
} label: {
Text("Stop Timer")
}
}
}
.padding()
}
private func createSet(count:Int) {
set.removeAll(keepingCapacity: true)
repeat {
let num = Int.random(in: 1...10000)
set.insert(num)
} while set.count < count
}
}
extension Int:Identifiable {
public var id:Self { self }
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I made a break point on Text(String(num)). Every time the timer was trigger, the GridView updated. Why this happened? As the model of grid didn't change.
Updated
If I put the timer in another view, the grid view wouldn't be trigger.
import SwiftUI
struct ContentView: View {
#State private var set = Set<Int>()
#State private var count = "10"
private let columns:[GridItem] = Array(repeating: .init(.flexible()), count: 3)
var body: some View {
VStack {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(Array(set)) { num in
Text(String(num))
}
}
}
.frame(width: 400, height: 400, alignment: .center)
HStack{
TextField("Create \(count) items", text: $count)
Button {
createSet(count: Int(count)!)
} label: {
Text("Create")
}
}
TimerView()
}
.padding()
}
private func createSet(count:Int) {
set.removeAll(keepingCapacity: true)
repeat {
let num = Int.random(in: 1...10000)
set.insert(num)
} while set.count < count
}
}
extension Int:Identifiable {
public var id:Self { self }
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import SwiftUI
struct TimerView: View {
#State private var timer:Timer? = nil
#State private var time = 0
var body: some View {
if let _ = timer {
Text(String(time))
.font(.title2)
.foregroundColor(.green)
}
HStack {
Button {
time = 100
let timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in
time -= 10
if time == 0 {
self.timer?.invalidate()
self.timer = nil
}
}
self.timer = timer
} label: {
Text("Start Timer")
}
Button {
self.timer?.invalidate()
self.timer = nil
} label: {
Text("Stop Timer")
}
}
}
}
struct TimerView_Previews: PreviewProvider {
static var previews: some View {
TimerView()
}
}
That´s pretty much how SwiftUI works. Every change to a #State var triggers the View to reevaluate. If you put your ForEach in another view it will only reevaluate if you change a var that changes that view. E.g. set or columns.
struct ExtractedView: View {
var columns: [GridItem]
var set: Set<Int>
var body: some View {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(Array(set)) { num in
Text(String(num))
}
}
}
.frame(width: 400, height: 400, alignment: .center)
}
}
It is encouraged in SwiftUI to make many small Views. The system driving this is pretty good in identifying what needs to be changed and what not. There is a very good WWDC video describing this.
WWDC

SwiftUI: Textfield shake animation when input is not valid

I want to create a shake animation when the User presses the "save"-button and the input is not valid. My first approach is this (to simplify I removed the modifiers and not for this case relevant attributes):
View:
struct CreateDeckView: View {
#StateObject var viewModel = CreateDeckViewModel()
HStack {
TextField("Enter title", text: $viewModel.title)
.offset(x: viewModel.isValid ? 0 : 10) //
.animation(Animation.default.repeatCount(5).speed(4)) // shake animation
Button(action: {
viewModel.buttonPressed = true
viewModel.saveDeck(){
self.presentationMode.wrappedValue.dismiss()
}
}, label: {
Text("Save")
})
}
}
ViewModel:
class CreateDeckViewModel: ObservableObject{
#Published var title: String = ""
#Published var buttonPressed = false
var validTitle: Bool {
buttonPressed && !(title.trimmingCharacters(in: .whitespacesAndNewlines) == "")
}
public func saveDeck(completion: #escaping () -> ()){ ... }
}
But this solution doesn't really work. For the first time when I press the button nothing happens. After that when I change the textfield it starts to shake.
using GeometryEffect,
struct ContentView: View {
#StateObject var viewModel = CreateDeckViewModel()
var body: some View {
HStack {
TextField("Enter title", text: $viewModel.title)
.modifier(ShakeEffect(shakes: viewModel.shouldShake ? 2 : 0)) //<- here
.animation(Animation.default.repeatCount(6).speed(3))
Button(action: {
viewModel.saveDeck(){
...
}
}, label: {
Text("Save")
})
}
}
}
//here
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: -30 * sin(position * 2 * .pi), y: 0))
}
init(shakes: Int) {
position = CGFloat(shakes)
}
var position: CGFloat
var animatableData: CGFloat {
get { position }
set { position = newValue }
}
}
class CreateDeckViewModel: ObservableObject{
#Published var title: String = ""
#Published var shouldShake = false
var validTitle: Bool {
!(title.trimmingCharacters(in: .whitespacesAndNewlines) == "")
}
public func saveDeck(completion: #escaping () -> ()){
if !validTitle {
shouldShake.toggle() //<- here (you can use PassThrough subject insteadof toggling.)
}
}
}

SwiftUI List not updating after Data change. (Needs switching the view)

I'm trying to change the SwiftUI list to update after tapping the checkbox button in my list.
When I tap on a list row checkbox button, I call a function to set the immediate rows as checked which ID is less then the selected one. I could modify the ArrayList as selected = 0 to selected = 1. But as my list is Published var it should emit the change to my list view. but it doesn't.
Here's what I've done:
// ViewModel
import Foundation
import Combine
class BillingViewModel: ObservableObject {
#Published var invoiceList = [InvoiceModel](){
didSet {
self.didChange.send(true)
}
}
#Published var shouldShow = true
let didChange = PassthroughSubject<Bool, Never>()
init() {
setValues()
}
func setValues() {
for i in 0...10 {
invoiceList.append(InvoiceModel(ispID: 100+i, selected: 0, invoiceNo: "Invoice No: \(100+i)"))
}
}
func getCombinedBalance(ispID: Int) {
DispatchQueue.main.async {
if let row = self.invoiceList.firstIndex(where: {$0.ispID == ispID}) {
self.changeSelection(row: row)
}
}
}
func changeSelection(row: Int) {
if invoiceList[row].selected == 0 {
let selectedRows = invoiceList.map({ $0.ispID ?? 0 <= invoiceList[row].ispID ?? 0 })
print(selectedRows)
for index in 0..<invoiceList.count {
if selectedRows[index] {
invoiceList[index].selected = 1
} else {
invoiceList[index].selected = 0
}
}
} else {
let selectedRows = invoiceList.map({ $0.ispID ?? 0 <= invoiceList[row].ispID ?? 0 })
print(selectedRows)
for index in 0..<invoiceList.count {
if selectedRows[index] {
invoiceList[index].selected = 1
} else {
invoiceList[index].selected = 0
}
}
}
}
}
// List View
import SwiftUI
struct ContentView: View {
#StateObject var billingViewModel = BillingViewModel()
#State var shouldShow = true
var body: some View {
NavigationView {
ZStack {
VStack {
List {
ForEach(billingViewModel.invoiceList) { invoice in
NavigationLink(
destination: InvoiceDetailsView(billingViewModel: billingViewModel)) {
invoiceRowView(billingViewModel: billingViewModel, invoice: invoice)
}
}
}
}
}.navigationBarTitle(Text("Invoice List"))
}
}
}
// Invoice Row View
import SwiftUI
struct invoiceRowView: View {
#StateObject var billingViewModel: BillingViewModel
#State var invoice: InvoiceModel
var body: some View {
VStack(alignment: .leading) {
HStack {
Button(action: {
if invoice.selected == 0 {
print(invoice)
billingViewModel.getCombinedBalance(ispID: invoice.ispID ?? 0)
} else {
print(invoice)
billingViewModel.getCombinedBalance(ispID: invoice.ispID ?? 0)
}
}, label: {
Image(systemName: invoice.selected == 0 ? "checkmark.circle" : "checkmark.circle.fill")
.resizable()
.frame(width: 32, height: 32, alignment: .center)
}).buttonStyle(PlainButtonStyle())
Text(invoice.invoiceNo ?? "Hello, World!").padding()
Spacer()
}
}
}
}
// Data Model
import Foundation
struct InvoiceModel: Codable, Identifiable {
var id = UUID()
var ispID: Int?
var selected: Int?
var invoiceNo: String?
}
You need to use binding instead of externally injected state (which is set just to copy of value), i.e.
struct invoiceRowView: View {
#StateObject var billingViewModel: BillingViewModel
#Binding var invoice: InvoiceModel
// .. other code
and thus inject binding in parent view
ForEach(billingViewModel.invoiceList.indices, id: \.self) { index in
NavigationLink(
destination: InvoiceDetailsView(billingViewModel: billingViewModel)) {
invoiceRowView(billingViewModel: billingViewModel, invoice: $billingViewModel.invoiceList[index])
}
}