SwiftUI: How to build a customizable timer countdown and its separate view - timer

I saw many blogs/articles on how to build a timer with Swift UI. But I cannot figure out to get what I really want working.
The 2 main issues I am facing are:
1. My TimerView is rebuilt whenever its parent view is rebuilt due to states changing
2. I am not able to send parameters to my #ObservedObject TimeCountDown property (2 parameters: duration coming from another view, and an onEnded completion)
class Round: ObservableObject {
#Publishedvar items: [Item]
var duration: Double
init(items: [Item], duration: Double) {
self.items = items
self.duration = duration
}
}
Struct ParentView: View {
#ObservedObject var round: Round
#State private var isRoundTerminated: Bool = false
var body: Some View {
VStack {
if isRoundTerminated {
RoundEndView()
} else {
TimerView(duration: round.duration, onEnded: onTimerTerminated)
RoundPlayView(items: round.items)
}
}
}
}
struct TimerView: View {
#ObservedObject countdown = TimeCountDown()
var duration: Double
var onEnded: (() -> Void)?
///// I DO NOT KNOW HOW TO PROPAGATE THE onEnded completion TO countdown:TimeCountDown
var body: Some View {
Text("There are \(countdown.remainingTime) remaining secs")
.onAppend() {
timer.start(duration: duration)
/// MAYBE I COULD ADD THE onEnded WITHIN THE start() CALL?
}
}
}
class TimeCountDown : ObservableObject {
var timer : Timer!
#Published var remainingTime: Double = 60
var onEnded: (() -> Void)?
init(onEnded: #escaping (() -> Void)?) {
self.onEnded = onEnded
}
func start(duration: Double) {
self.timer?.invalidate()
self.remainingTime = duration
timer = Timer.scheduledTimer(timeInterval:1, target: self, selector:#selector(updateCountdown), userInfo: nil, repeats: true)
}
#objc private func updateCount() {
remainingTime -= 1
if remainingTime <= 0 {
killTimer()
self.onEnded?()
}
}
private func killTimer() {
timer?.invalidate()
timer = nil
}
}
However that does not work...
I also tried to implement the following TimerView:
struct CountdownView: View {
#State private var remainingTime: Int = 60
#Binding var countingDown: Bool
var onEnded: (() -> Void)?
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
init(durations: TimerDuration, onEnded: (() -> Void)?, start: Binding<Bool>) {
self.onEnded = onEnded
self._countingDown = start
self.remainingTime = durations.duration
}
var body: some View {
Text("Remaining \(remainingTime) secs")
.onReceive(timer) {_ in
if self.countingDown {
if self.remainingTime > 0 {
self.remainingTime -= 1
} else {
self.onTerminated()
}
}
}
}
func onTerminated() {
timer.upstream.connect().cancel()
self.remainingTime = 0
onEnded?()
}
}
However when the ParentView is rebuilt very often (due to modifications to round.items (#Published from Round:ObservableObject) the timer can be frozen.

#George_E, please find below my update code (Please note that I simplified the nextWord() func (a but useless here) but it does modify the round.remainingDeck which is a Published property within the ObervableObject Round. That triggers a rebuild of RoundPlayView and that freezes the timer):
import SwiftUI
import Combine
class Round: ObservableObject {
#Published var remainingDeck: [String] = ["Round#1", "Round#2", "Round#3", "Round4"]
var roundDuration: Int = 5
}
struct ContentView: View {
#ObservedObject var round = Round()
var body: Some View {
RoundPlayView(round: round)
}
}
struct RoundPlayView: View {
#ObservedObject var round: Round
var duration: Int {
return round.roundDuration
}
#State private var timerStart: Bool = true
#State private var isTerminated: Bool = false
init(round: Round) {
self.round = round
}
var body: some View {
return VStack {
if self.isTerminated {
EmptyView()
} else {
VStack {
CountdownView(duration: duration, onEnded: timerTerminated, start: $timerStart)
Button(action: { self.addWord() }) {
Text("Add words to the deck")
}
}
}
}
}
private func nextWord() {
round.remainingDeck.append("New Word")
}
private func timerTerminated() {
terminateRound()
}
private func terminateRound() {
self.isTerminated = true
}
}
and here is my TimerView code:
struct CountdownView: View {
#State private var remainingTime: Int = 60
#Binding var countingDown: Bool
var onEnded: (() -> Void)?
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
init(duration: Int, onEnded: (() -> Void)?, start: Binding<Bool>) {
self.onEnded = onEnded
self._countingDown = start
self.remainingTime = duration
}
var body: some View {
Text("Remaining \(remainingTime) secs")
.onReceive(timer) {_ in
if self.countingDown {
if self.remainingTime > 0 {
self.remainingTime -= 1
} else {
self.onTerminated()
}
}
}
}
func onTerminated() {
timer.upstream.connect().cancel()
self.remainingTime = 0
onEnded?()
}
}

Related

DetailView only calls on first item in array when using LazyVGrid

I had a list that I could add items to and when I click on them it opened up the correct detail view. I recently swapped the list out for LazyVGrid but when I click on a grid item, the detail view only calls on the first item in the array.
This is the list view:
import SwiftUI
struct ListView: View {
#EnvironmentObject var listViewModel: ListViewModel
#State var addItemView = false
#State var detailView = false
let columns = [GridItem(.flexible()), GridItem(.flexible())]
var body: some View {
NavigationView {
ZStack {
ZStack {
Color(.white)
}
VStack {
Button(action: {
self.addItemView = true
}, label: {
Text("Add")
}).sheet(isPresented: $addItemView, content: {
AddItemView()
})
LazyVGrid(columns: columns, spacing: 20) { // <-- the only line i comment when using list
// List {
ForEach(listViewModel.item, id:\.id){ item in
Button(action: {
self.detailView = true
}, label: {
ListRowView(item: item)
}).sheet(isPresented: $detailView, content: {
DetailView(item: item)
})
}
// .onDelete(perform: listViewModel.deleteItem)
// .onMove(perform: listViewModel.moveItem)
// .listRowBackground(Color.blue)
}
// .listStyle(PlainListStyle())
Spacer()
}
}
}
}
}
struct ListRowView: View { // <-- using this as grid item
#State var item:Item
var body: some View{
VStack {
Text(item.name).foregroundColor(.white)
}.frame(width: 150, height: 150, alignment: .center)
.background(Color.blue)
.cornerRadius(10)
}
}
The add item and detail view:
struct AddItemView: View {
#Environment(\.presentationMode) var presentationMode
#EnvironmentObject var listViewModel: ListViewModel
#State var id = UUID()
#State var name = ""
var body: some View {
TextField("Enter Name", text: $name)
Button(action: {
addItem()
}, label: {
Text("Done")
})
}
func addItem() {
listViewModel.addItem(id: id.uuidString, name: name)
presentationMode.wrappedValue.dismiss()
}
}
struct DetailView: View {
#State var item:Item
var body: some View {
Text(item.name)
}
}
And this is how im adding each item:
import Foundation
struct Item: Hashable, Codable, Equatable {
var id:String
var name: String
}
class ListViewModel: ObservableObject {
#Published var item: [Item] = [] {
didSet {
saveItem()
}
}
let itemsKey: String = "items_key"
init() {
getItems()
}
func getItems() {
guard
let data = UserDefaults.standard.data(forKey: itemsKey),
let savedItems = try? JSONDecoder().decode([Item].self, from: data)
else { return }
self.item = savedItems
}
func deleteItem(indexSet: IndexSet){
item.remove(atOffsets: indexSet)
}
func moveItem(from: IndexSet, to: Int){
item.move(fromOffsets: from, toOffset: to)
}
func addItem(id: String, name: String){
let newItem = Item(id: id, name: name)
item.append(newItem)
print(newItem)
}
func saveItem() {
if let encodedData = try? JSONEncoder().encode(item) {
UserDefaults.standard.set(encodedData, forKey: itemsKey)
}
}
}
I'm not sure why the LazyVGrid is only calling on the first item, any help would be appreciated
The problem is here:
#State var detailView = false /// will only work for 1 sheet, not multiple...
...
ForEach(listViewModel.item, id:\.id){ item in
Button(action: {
self.detailView = true
}, label: {
ListRowView(item: item)
}).sheet(isPresented: $detailView, content: { /// not good to have `sheet` inside ForEach
DetailView(item: item)
})
}
In each iteration of the ForEach, you have a sheet. That's a lot of sheets... and when detailView is set to true, all of them will try to present. By chance, the first once gets presented.
Instead, you'll need to use sheet(item:onDismiss:content:). This alternate version of sheet is specifically made for your purpose — when you have an array of Items and want to present a sheet for that specific Item.
First, you'll need to make Item conform to Identifiable.
/// here!
struct Item: Hashable, Codable, Equatable, Identifiable {
Then, replace the old trigger, #State var detailView = false, with #State var selectedDetailItem: Item?. Also, make sure to move the sheet outside of the ForEach, so it doesn't repeat. Now, the sheet will only present when selectedDetailItem is not nil.
struct ListView: View {
#EnvironmentObject var listViewModel: ListViewModel
#State var addItemView = false
#State var selectedDetailItem: Item? /// here!
let columns = [GridItem(.flexible()), GridItem(.flexible())]
var body: some View {
NavigationView {
ZStack {
ZStack {
Color(.white)
}
VStack {
Button(action: {
self.addItemView = true
}, label: {
Text("Add")
}).sheet(isPresented: $addItemView, content: {
AddItemView()
})
LazyVGrid(columns: columns, spacing: 20) {
ForEach(listViewModel.item, id:\.id){ item in
Button(action: {
self.selectedDetailItem = item /// set the selected item
}, label: {
ListRowView(item: item)
})
}
}
Spacer()
}
}
} /// present the sheet here
.sheet(item: $selectedDetailItem) { selectedItem in
DetailView(item: selectedItem)
}
}
}
Result:

Problems with switching array elements

I have a class that has a calculated property, which is an array consisting of instances of the structure.
struct Team: Identifiable, Codable, Hashable {
var id = UUID()
var name : String
}
class TeamRow : ObservableObject {
#Published var teamsArray : [Team] = [] {
didSet {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(teamsArray) {
UserDefaults.standard.setValue(encoded, forKey: "Teams")
}
}
}
init() {
if let teams = UserDefaults.standard.data(forKey: "Teams") {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode([Team].self, from: teams) {
self.teamsArray = decoded
return
}
}
}
}
Also, I have a view, with the ability to add elements(teams) to this array using a sheet.
struct PlayersRow: View {
#ObservedObject var teams = TeamRow()
#State private var team = ""
#State private var showTeamAddSheet = false
var body: some View {
Form {
ForEach(teams.teamsArray) { team in
Text(team.name)
.font(.system(size: 20))
.padding(.horizontal, 110)
.padding(.vertical, 10)
}
}
.navigationBarTitle("Teams")
.navigationBarItems(trailing: Button(action: {
self.showTeamAddSheet = true
}) {
Image(systemName: "plus")
.foregroundColor(.black)
.font(.system(size: 30))
})
.sheet(isPresented: $showTeamAddSheet) {
AddPlayerView(teams: self.teams)
}
}
}
This is a sheet view.
struct AddPlayerView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var teams : TeamRow
#State private var team = ""
var body: some View {
NavigationView {
Form {
TextField("Add new team", text: $team)
}
.navigationBarItems(trailing: Button(action: {
let newTeam = Team(name: self.team)
self.teams.teamsArray.append(newTeam)
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Сохранить")
.font(.custom("coolJazz", size: 20))
.foregroundColor(.black)
}))
.navigationBarTitle("Add Team")
}
}
}
And I have a view where I need to output the array elements one by one, using a button, clicked on the button, the view screen displayed 0 element, clicked on the button, displayed first element, etc.
struct GameView: View {
#ObservedObject var teams = TeamRow()
//#State var index = 0
var body: some View {
VStack(spacing: 40) {
//Text(teams.teamsArray[index]) this isn't worked, return an error: Initializer 'init(_:)' requires that 'Team' conform to 'StringProtocol'
Button(action: {
}) {
Text("press it")
}
}
}
}
struct GameView_Previews: PreviewProvider {
static var previews: some View {
GameView().environmentObject(TeamRow())
}
}
if the array is set initially in the class, I have no problem displaying the elements on the screen by increasing the index, but how to solve this problem I do not know...
Can some one explain newbie?
Change your GameView Code to following:
struct GameView: View {
#ObservedObject var teams = TeamRow()
#State var index = 0
var body: some View {
VStack(spacing: 40) {
if teams.teamsArray.count > index {
Text(teams.teamsArray[index].name)
}
Button(action: {
index += 1
}) {
Text("press it")
}
}
}
}

#Published array in multiple ViewModels - SwiftUI

I have app with only two Views, both Views has own ViewModel. ViewA shows and manipulate objects from selectedNumbers array. ViewB owns all available objects(numbers) - in this view I want manipulate selectedNumbers array, that is used by ViewA.
I'm trying to find out, how to share these selectedNumbers array between these two ViewModels. I tried to use EnvironmentObject, StaticObject etc. But nothing works as I need. What approach should I use to achieve desired result. Thanks for help!
import SwiftUI
struct ViewA: View {
#ObservedObject var viewModel = ViewModelA()
var body: some View {
VStack {
Text("\(viewModel.number)")
.font(.largeTitle)
.padding()
.onTapGesture {
viewModel.showNext()
}
ViewB()
}
}
}
class ViewModelA: ObservableObject {
var numbers: [Int] = []
#Published var number: Int
var index = 0
init() {
number = numbers.isEmpty ? 0 : numbers[index]
}
func showNext() {
guard !numbers.isEmpty else { return }
if index < numbers.count - 1 {
index += 1
} else {
index = 0
}
number = numbers[index]
}
}
struct ViewB: View {
#ObservedObject var viewModel = ViewModelB()
var body: some View {
HStack {
ForEach(viewModel.numbers, id: \.self) { number in
Text("\(number)")
.foregroundColor(viewModel.selectedNumbers.contains(number) ? .red : .black)
.onTapGesture {
viewModel.updateSelection(number)
}
}
}
}
}
class ViewModelB: ObservableObject {
#Published var numbers: [Int] = []
#Published var selectedNumbers: [Int] = []
init() {
numbers.append(contentsOf: [1,2,3,4,5,6,7,8])
}
func updateSelection(_ number: Int) {
if selectedNumbers.contains(number) {
selectedNumbers.remove(number)
} else {
selectedNumbers.append(number)
}
}
}
extension Array where Element: Equatable {
mutating func remove(_ object: Element) {
guard let index = firstIndex(of: object) else {return}
remove(at: index)
}
}
You can still keep the logic separate, but you need to keep a single source of truth, and if you want to share data among views, you either need to pass Bindings or you can also share #ObservedObject among Subviews.
import SwiftUI
struct ViewA: View {
#ObservedObject var viewModel = ViewModelA(modelB: ViewModelB())
var body: some View {
VStack {
Text("\(viewModel.number)")
.font(.largeTitle)
.padding()
.onTapGesture {
viewModel.showNext()
}
ViewB(model: viewModel)
}
}
}
class ViewModelA: ObservableObject {
var numbers: [Int] = []
#Published var number: Int
#Published var modelB:ViewModelB
var index = 0
init(modelB:ViewModelB) {
self.modelB = modelB
number = numbers.isEmpty ? 0 : modelB.selectedNumbers[index]
}
func showNext() {
guard !modelB.selectedNumbers.isEmpty else { return }
if index < modelB.selectedNumbers.count - 1 {
index += 1
} else {
index = 0
}
number = modelB.selectedNumbers[index]
}
}
struct ViewB: View {
#ObservedObject var model : ViewModelA
var body: some View {
HStack {
ForEach(model.modelB.selectedNumbers, id: \.self) { number in
Text("\(number)")
.foregroundColor(model.modelB.selectedNumbers.contains(number) ? .red : .black)
.onTapGesture {
model.modelB.updateSelection(number)
}
}
}
}
}
struct ViewModelB {
var selectedNumbers: [Int] = []
init() {
selectedNumbers.append(contentsOf: [1,2,3,4,5,6,7,8])
}
mutating func updateSelection(_ number: Int) {
if selectedNumbers.contains(number) {
selectedNumbers.remove(number)
} else {
selectedNumbers.append(number)
}
}
}
extension Array where Element: Equatable {
mutating func remove(_ object: Element) {
guard let index = firstIndex(of: object) else {return}
remove(at: index)
}
}

How to sort a array with localized String in SwiftUI

Hi my problem is that i want to sort an array of objects by the object's title property. When i change the title's type from String to LocalizedStringKey i get an error. Is there a way to sort the correct translated string behind the localizedStringKey.
struct Item: Codable,Comparable, Hashable, Identifiable {
static func < (lhs: Item, rhs: Item) -> Bool {
return lhs.title < rhs.title
}
var id: Int
let image: String
let color: String
// title should be LocalizedStringKey
let title: String
}
......
#State private var sortedDown = false
var filteredItems: [Item] {
var sortedItems: [Item]
let filteredItems = modelData.items.filter { item in
(!showFavoritesOnly || item.isFavorite)
}
if sortedDown {
sortedItems = filteredItems.sorted(by: { (item1, item2) -> Bool in
return item1.title > item2.title
})
} else {
sortedItems = filteredItems.sorted(by: { (item1, item2) -> Bool in
return item1.title < item2.title
})
}
return sortedItems
}
var body: some View {
NavigationView {
List {
Toggle(isOn: $showFavoritesOnly, label: {
Text("showFavorites")
})
ForEach(filteredItems) { (item) in
.....
``
Don't forget to provide a minimal reproducible example when you ask questions. You will get better and more focused answers. Below is one way you can do it.
class LocalizeTitleViewModel: ObservableObject {
#Published var items: [SampleItem] = [SampleItem(title: "orange", isFavorite: true), SampleItem(title: "pink", isFavorite: false), SampleItem(title: "red", isFavorite: true)]
#Published var sortedDown = false
#Published var showFavoritesOnly = false
var filteredItems: [SampleItem] {
var sortedItems: [SampleItem]
let filteredItems = items.filter { item in
(!showFavoritesOnly || item.isFavorite)
}
if sortedDown {
sortedItems = filteredItems.sorted(by: { (item1, item2) -> Bool in
return item1.localizedTitle > item2.localizedTitle
})
} else {
sortedItems = filteredItems.sorted(by: { (item1, item2) -> Bool in
return item1.localizedTitle < item2.localizedTitle
})
}
return sortedItems
}
}
struct LocalizeTitle: View {
#StateObject var modelData: LocalizeTitleViewModel = LocalizeTitleViewModel()
var body: some View {
NavigationView {
List {
Toggle(isOn: $modelData.showFavoritesOnly, label: {
Text("showFavorites")
})
ForEach(modelData.filteredItems) { (item) in
Text(item.localizedTitle)
}
}
}
}
}
struct SampleItem: Identifiable {
var id: UUID = UUID()
let title: String
var isFavorite: Bool
var localizedTitle: String{
get{
//You need a Localizable.strings file in your project that contains all your `title`
return NSLocalizedString(self.title, comment: "Sample Item title")
}
}
}
struct LocalizeTitle_Previews: PreviewProvider {
static var previews: some View {
LocalizeTitle()
}
}
LocalizedStringKey does not conform to the Comparable protocol, so you cannot use the < or > operators on it.
You can either just use a regular String, or use custom comparison logic.

ObservableObject bug or not

I still have the problem when the count reaches 3, the reset function only stops it, but the count is not set to 0. I use the reset function with a button, it works perfectly. i would like to understand it and hope someone knows the reason for it?
import SwiftUI
import Combine
import Foundation
class WaitingTimerClass: ObservableObject {
#Published var waitingTimerCount: Int = 0
var waitingTimer = Timer()
func start() {
self.waitingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
self.waitingTimerCount += 1 }}
func stop() { waitingTimer.invalidate() }
func reset() { waitingTimerCount = 0; waitingTimer.invalidate() }
}
struct ContentView: View {
#ObservedObject var observed = WaitingTimerClass()
var body: some View {
VStack {
Text("\(self.observed.waitingTimerCount)")
.onAppear { self.observed.start() }
.onReceive(observed.$waitingTimerCount) { count in
guard count == 3 else {return}
self.observed.reset() // does not work
}
Button(action: {self.observed.start()}) {
Text("Start") }
Button(action: {self.observed.reset()}) { // works
Text("Reset") }
Button(action: {self.observed.stop()}) {
Text("Stop") }
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
It is because reset changes property affecting UI during body construction, so ignored. It should be changed as below
func reset() {
waitingTimer.invalidate()
DispatchQueue.main.async {
self.waitingTimerCount = 0
}
}

Resources