How to make my timer more precise in SwiftUI? - swift

I would like to develop an app that includes a timer - everything works fine so far - yet I have the problem that the CircleProgress is not quite at 0 when the counter is. (as you can see in the picture below)
Long story short - my timer is not precise... How can I make it better?
So this is my code:
This is the View where I give my Binding to the ProgressCircleView:
struct TimerView: View {
//every Second change Circle and Value (Circle is small because of the animation)
let timerForText = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
let timerForCircle = Timer.publish(every: 0.001, on: .main, in: .common).autoconnect()
#State var progress : Double = 1.0
#State var counterCircle : Double = 0.0
#State var counterText : Int = 0
#State var timerSeconds : Int = 60
let customInverval : Int
var body: some View {
ZStack{
ProgressCircleView(progress: self.$progress, timerSeconds: self.$timerSeconds, customInterval: customInverval)
.padding()
.onReceive(timerForCircle){ time in
//if counterCircle is the same value as Interval -> break
if self.counterCircle == Double(customInverval){
self.timerForCircle.upstream.connect().cancel()
} else {
decreaseProgress()
}
counterCircle += 0.001
}
VStack{
Text("\(timerSeconds)")
.font(.system(size: 80))
.bold()
.onReceive(timerForText){time in
//wenn counterText is the same value as Interval -> break
if self.counterText == customInverval{
self.timerForText.upstream.connect().cancel()
} else {
incrementTimer()
print("timerSeconds: \(self.timerSeconds)")
}
counterText += 1
}.multilineTextAlignment(.center)
}
.accessibilityElement(children: .combine)
}.padding()
}
func decreaseProgress() -> Void {
let decreaseValue : Double = 1/(Double(customInverval)*1000)
self.progress -= decreaseValue
}
func incrementTimer() -> Void {
let decreaseValue = 1
self.timerSeconds -= decreaseValue
}
}
And this is my CircleProgressClass:
struct ProgressCircleView: View {
#Binding var progress : Double
#Binding var timerSeconds : Int
let customInterval : Int
var body: some View {
ZStack{
Circle()
.stroke(lineWidth: 25)
.opacity(0.08)
.foregroundColor(.black)
Circle()
.trim(from: 0.0, to: CGFloat(Double(min(progress, 1.0))))
.stroke(style: StrokeStyle(lineWidth: 20.0, lineCap: .round, lineJoin: .round))
.rotationEffect(.degrees(270.0))
.foregroundColor(getCircleColor(timerSeconds: timerSeconds))
.animation(.linear)
}
}
}
func getCircleColor(timerSeconds: Int) -> Color {
if (timerSeconds <= 10 && timerSeconds > 3) {
return Color.yellow
} else if (timerSeconds <= 3){
return Color.red
} else {
return Color.green
}
}

You cannot control the timer, it will never be entirely accurate.
Instead, I suggest you save the end date and calculate your progress based on it:
struct TimerView: View {
//every Second change Circle and Value (Circle is small because of the animation)
let timerForText = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
let timerForCircle = Timer.publish(every: 0.001, on: .main, in: .common).autoconnect()
#State var progress : Double = 1.0
#State var timerSeconds : Int = 60
#State var endDate: Date? = nil
let customInverval : Int
var body: some View {
ZStack{
ProgressCircleView(progress: self.$progress, timerSeconds: self.$timerSeconds, customInterval: customInverval)
.padding()
.onReceive(timerForCircle){ _ in
decreaseProgress()
}
VStack{
Text("\(timerSeconds)")
.font(.system(size: 80))
.bold()
.onReceive(timerForText){ _ in
incrementTimer()
}.multilineTextAlignment(.center)
}
.accessibilityElement(children: .combine)
}.padding()
.onAppear {
endDate = Date(timeIntervalSinceNow: TimeInterval(customInverval))
}
}
func decreaseProgress() -> Void {
guard let endDate = endDate else { return}
progress = max(0, endDate.timeIntervalSinceNow / TimeInterval(customInverval))
if endDate.timeIntervalSinceNow <= 0 {
timerForCircle.upstream.connect().cancel()
}
}
func incrementTimer() -> Void {
guard let endDate = endDate else { return}
timerSeconds = max(0, Int(endDate.timeIntervalSinceNow.rounded()))
if endDate.timeIntervalSinceNow <= 0 {
timerForText.upstream.connect().cancel()
print("stop")
}
}
}

Related

SwiftUI-How to formate seconds into Minutes:Seconds?

I am trying to make a timer in SwiftUI and everything works well so far but I can't figure out how to format the seconds into seconds and minutes. So for example if the countdown was 299 seconds, I want it to show 4(mins):59(secs) instead of just 300 seconds.
I saw a video on youtube making a timer too and they managed to format it via making minutes and seconds constants then format it via a function but it didn't really work for me.
import SwiftUI
struct TimerView: View {
var timerEnd = 0
#State var countdownTimer = 300
#State var timerRunning = false
#State var isPaused = false
#State var isActive = false
#State var showingAlert = false
let timer = Timer.publish(every: 1, tolerance: 0.5, on: .main, in: .common).autoconnect()
func format(result: Int) -> String {
let value = String(format:"%d:%02\(300/60)", countdownTimer)
return value
}
func reset() {
countdownTimer = 300
timerRunning = true
}
var body: some View {
VStack {
Text("Time: \(format(result: countdownTimer))")
.padding()
.onReceive(timer) { _ in
if countdownTimer > 0 && timerRunning {
countdownTimer -= 1
} else {
timerRunning = false
}
}
.font(.system(size: 30))
HStack(spacing:30) {
Button(timerRunning ? "Reset" : "Start") {
reset()
}
.padding()
.background((Color(red: 184/255, green: 243/255, blue: 255/255)))
.foregroundColor(.black)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
Button(timerRunning ? "Pause" : "Resume") {
if timerRunning == true {
timerRunning = false
} else {
timerRunning = true
}
}
.padding()
.foregroundColor(.black)
.background(.red)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
}
}
}
}
struct TimerView_Previews: PreviewProvider {
static var previews: some View {
TimerView()
}
}
You can do with an extension class like this. After that code you will see a method in your int value.
You can use countdownTimer.convertDurationToString()
extension Int {
public func hmsFrom() -> (Int, Int, Int) {
return (self / 3600, (self % 3600) / 60, (self % 3600) % 60)
}
public func convertDurationToString() -> String {
var duration = ""
let (hour, minute, second) = self.hmsFrom()
if (hour > 0) {
duration = self.getHour(hour: hour)
}
return "\(duration)\(self.getMinute(minute: minute))\(self.getSecond(second: second))"
}
private func getHour(hour: Int) -> String {
var duration = "\(hour):"
if (hour < 10) {
duration = "0\(hour):"
}
return duration
}
private func getMinute(minute: Int) -> String {
if (minute == 0) {
return "00:"
}
if (minute < 10) {
return "0\(minute):"
}
return "\(minute):"
}
private func getSecond(second: Int) -> String {
if (second == 0){
return "00"
}
if (second < 10) {
return "0\(second)"
}
return "\(second)"
}
}

SwiftUI- Timer auto adds 30 minutes to countdown

I'm trying to make a timer in swiftui but whenever I run it, it auto adds 30 minutes to the countdown. For example, when I set the countdown time to 5 minutes and click the "Start" button, it will show up as 35 minutes instead but when I click the button again, it will then just keep switching to random times. Above is the random times it will switch to.
I got this timer from a tutorial on youtube by Indently but changed some things to fit what I wanted it to do. I tried to set a custom time so the timer will always countdown from 5 minutes. From my understanding, the timer works by taking the difference between the current date and the end date then using the amount of time difference as the countdown. Below is the code for the TimerStruct (ViewModel) and the TimerView.
TimerStruct:
import Foundation
extension TimerView {
final class ViewModel: ObservableObject {
#Published var isActive = false
#Published var showingAlert = false
#Published var time: String = "5:00"
#Published var minutes: Float = 5.0 {
didSet {
self.time = "\(Int(minutes)):00"
}
}
var initialTime = 0
var endDate = Date()
// Start the timer with the given amount of minutes
func start(minutes: Float) {
self.initialTime = 5
self.endDate = Date()
self.isActive = true
self.endDate = Calendar.current.date(byAdding: .minute, value: Int(minutes), to: endDate)!
}
// Reset the timer
func reset() {
self.minutes = Float(initialTime)
self.isActive = false
self.time = "\(Int(minutes)):00"
}
// Show updates of the timer
func updateCountdown(){
guard isActive else { return }
// Gets the current date and makes the time difference calculation
let now = Date()
let diff = endDate.timeIntervalSince1970 - now.timeIntervalSince1970
// Checks that the countdown is not <= 0
if diff <= 0 {
self.isActive = false
self.time = "0:00"
self.showingAlert = true
return
}
// Turns the time difference calculation into sensible data and formats it
let date = Date(timeIntervalSince1970: diff)
let calendar = Calendar.current
let minutes = calendar.component(.minute, from: date)
let seconds = calendar.component(.second, from: date)
// Updates the time string with the formatted time
self.minutes = Float(minutes)
self.time = String(format:"%d:%02d", minutes, seconds)
}
}
}
TimerView:
import SwiftUI
struct TimerView: View {
#ObservedObject var vm = ViewModel()
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
let width: Double = 250
var body: some View {
VStack {
Text("Timer: \(vm.time)")
.font(.system(size: 50, weight: .medium, design: .rounded))
.alert("Timer done!", isPresented: $vm.showingAlert) {
Button("Continue", role: .cancel) {
}
}
.padding()
HStack(spacing:50) {
Button("Start") {
vm.start(minutes: Float(vm.minutes))
}
.padding()
.background((Color(red: 184/255, green: 243/255, blue: 255/255)))
.foregroundColor(.black)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
//.disabled(vm.isActive)
if vm.isActive == true {
Button("Pause") {
vm.isActive = false
//self.timer.upstream.connect().cancel()
}
.padding()
.foregroundColor(.black)
.background(.red)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
} else {
Button("Resume") {
vm.isActive = true
}
.padding()
.foregroundColor(.black)
.background(.green)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
}
}
.frame(width: width)
}
.onReceive(timer) { _ in
vm.updateCountdown()
}
}
}
struct TimerView_Previews: PreviewProvider {
static var previews: some View {
TimerView()
}
}
I noticed and fixed a number of things in your code:
start() is being called with the current value of vm.minutes, so it is going to start from that value and not 5. I changed it to use self.initialTime which means it's currently not using the value passed in. You need to decide if start() really wants to take a value and how to use it.
reset() wasn't being called. I call it from start().
Pause was only pausing the screen update. I changed it to keep track of the start time of the pause and to compute the amount of time paused so that it could accurately update the displayed time.
I made the Pause/Resume button one button with conditional values for title and color based upon vm.active.
Here is the updated code:
extension TimerView {
final class ViewModel: ObservableObject {
#Published var isActive = false
#Published var showingAlert = false
#Published var time: String = "5:00"
#Published var minutes: Float = 5.0 {
didSet {
self.time = "\(Int(minutes)):00"
}
}
var initialTime = 0
var endDate = Date()
var pauseDate = Date()
var pauseInterval = 0.0
// Start the timer with the given amount of minutes
func start(minutes: Float) {
self.initialTime = 5
self.reset()
self.endDate = Date()
self.endDate = Calendar.current.date(byAdding: .minute, value: self.initialTime, to: endDate)!
self.isActive = true
}
// Reset the timer
func reset() {
self.isActive = false
self.pauseInterval = 0.0
self.minutes = Float(initialTime)
self.time = "\(Int(minutes)):00"
}
func pause() {
if self.isActive {
pauseDate = Date()
} else {
// keep track of the total time we're paused
pauseInterval += Date().timeIntervalSince(pauseDate)
}
self.isActive.toggle()
}
// Show updates of the timer
func updateCountdown(){
guard isActive else { return }
// Gets the current date and makes the time difference calculation
let now = Date()
let diff = endDate.timeIntervalSince1970 + self.pauseInterval - now.timeIntervalSince1970
// Checks that the countdown is not <= 0
if diff <= 0 {
self.isActive = false
self.time = "0:00"
self.showingAlert = true
return
}
// Turns the time difference calculation into sensible data and formats it
let date = Date(timeIntervalSince1970: diff)
let calendar = Calendar.current
let minutes = calendar.component(.minute, from: date)
let seconds = calendar.component(.second, from: date)
// Updates the time string with the formatted time
//self.minutes = Float(minutes)
self.time = String(format:"%d:%02d", minutes, seconds)
}
}
}
struct TimerView: View {
#ObservedObject var vm = ViewModel()
let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
let width: Double = 250
var body: some View {
VStack {
Text("Timer: \(vm.time)")
.font(.system(size: 50, weight: .medium, design: .rounded))
.alert("Timer done!", isPresented: $vm.showingAlert) {
Button("Continue", role: .cancel) {
}
}
.padding()
HStack(spacing:50) {
Button("Start") {
vm.start(minutes: Float(vm.minutes))
}
.padding()
.background((Color(red: 184/255, green: 243/255, blue: 255/255)))
.foregroundColor(.black)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
//.disabled(vm.isActive)
Button(vm.isActive ? "Pause" : "Resume") {
vm.pause()
//vm.isActive = false
//self.timer.upstream.connect().cancel()
}
.padding()
.foregroundColor(.black)
.background(vm.isActive ? .red : .green)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
}
.frame(width: width)
}
.onReceive(timer) { _ in
vm.updateCountdown()
}
}
}

Changing translation of a DragGesture swift

I am working with a slider and dealing with translation. Once the slider performs an action, I mock an api call and then on completion the image is unlocked but now I would like to return the slider to the original position once the api call is completed. Here is what the slider looks like in code.
struct DraggingComponent: View {
#Binding var isLocked: Bool
#Binding var isLoading: Bool
let maxWidth: CGFloat
#State private var width = CGFloat(50)
private let minWidth = CGFloat(50)
init(isLocked: Binding<Bool>, isLoading: Binding<Bool>, maxWidth: CGFloat) {
_isLocked = isLocked
self._isLoading = isLoading
self.maxWidth = maxWidth
}
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(Color.black)
.opacity(width / maxWidth)
.frame(width: width)
.overlay(
Button(action: { }) {
ZStack {
image(name: "lock", isShown: isLocked)
progressView(isShown: isLoading)
image(name: "lock.open", isShown: !isLocked && !isLoading)
}
.animation(.easeIn(duration: 0.35).delay(0.55), value: !isLocked && !isLoading)
}
.buttonStyle(BaseButtonStyle())
.disabled(!isLocked || isLoading),
alignment: .trailing
)
.simultaneousGesture(
DragGesture()
.onChanged { value in
guard isLocked else { return }
if value.translation.width > 0 {
width = min(max(value.translation.width + minWidth, minWidth), maxWidth)
}
}
.onEnded { value in
guard isLocked else { return }
if width < maxWidth {
width = minWidth
UINotificationFeedbackGenerator().notificationOccurred(.warning)
} else {
UINotificationFeedbackGenerator().notificationOccurred(.success)
withAnimation(.spring().delay(0.5)) {
isLocked = false
}
}
}
)
.animation(.spring(response: 0.5, dampingFraction: 1, blendDuration: 0), value: width)
}
private func image(name: String, isShown: Bool) -> some View {
Image(systemName: name)
.font(.system(size: 20, weight: .regular, design: .rounded))
.foregroundColor(Color.black)
.frame(width: 42, height: 42)
.background(RoundedRectangle(cornerRadius: 14).fill(.white))
.padding(4)
.opacity(isShown ? 1 : 0)
.scaleEffect(isShown ? 1 : 0.01)
}
private func progressView(isShown: Bool) -> some View {
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
.opacity(isShown ? 1 : 0)
.scaleEffect(isShown ? 1 : 0.01)
}
}
Where it is used:
#State private var isLocked = true
#State private var isLoading = false
GeometryReader { geometry in
ZStack(alignment: .leading) {
BackgroundComponent()
DraggingComponent(isLocked: $isLocked, isLoading: $isLoading, maxWidth: geometry.size.width)
}
}
.frame(height: 50)
.padding()
.padding(.bottom, 20)
.onChange(of: isLocked) { isLocked in
guard !isLocked else { return }
simulateRequest()
}
private func simulateRequest() {
isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
isLoading = false
}
}
How can I get the translation back to the initial position.
Pull your width #State up into the containing view and pass it on as #Binding. After simulateRequest set it to its initial state.
In your DraggingComponent use
struct DraggingComponent: View {
#Binding var width: CGFloat
.....
in the View that contains DraggingComponent:
#State private var width = CGFloat(50)
and:
.onChange(of: isLocked) { isLocked in
guard !isLocked else { return }
simulateRequest()
self.width = CGFloat(50) //reset it here probably with animation
}

How to dynamically change GridItems in LazyVGrid with MagnificationGesture [Zoom In, Out] in SwiftUI?

The idea is to recreate the same photo layout behaviour like in Apple Photo Library when I can zoom in and out with 1, 3 or 5 photos in a row. I'm stack in a half way. For that I use a MagnificationGesture() and based on gesture value I update number of GridItems() in LazyVGrid().
Please let me know how to achieve it. Thanks a lot ๐Ÿ™
Here's code:
import SwiftUI
struct ContentView: View {
let colors: [Color] = [.red, .purple, .yellow, .green, .blue, .mint, .orange]
#State private var colums = Array(repeating: GridItem(), count: 1)
// #GestureState var magnifyBy: CGFloat = 1.0
#State var magnifyBy: CGFloat = 1.0
#State var lastMagnifyBy: CGFloat = 1.0
let minMagnifyBy = 1.0
let maxMagnifyBy = 5.0
var magnification: some Gesture {
MagnificationGesture()
// .updating($magnifyBy) { (currentState, pastState, trans) in
// pastState = currentState.magnitude
// }
.onChanged { state in
adjustMagnification(from: state)
print("Current State \(state)")
}
.onEnded { state in
adjustMagnification(from: state)
// withAnimation(.spring()) {
// validateMagnificationLimits()
// }
lastMagnifyBy = 1.0
}
}
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: colums) {
ForEach(1..<101) { number in
colors[number % colors.count]
.overlay(Text("\(number)").font(.title2.bold()).foregroundColor(.white))
.frame(height: 100)
}
}
.scaleEffect(magnifyBy)
.gesture(magnification)
.navigationTitle("๐Ÿงจ Grid")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation(.spring(response: 0.8)) {
colums = Array(repeating: .init(), count: colums.count == 5 ? 1 : colums.count % 5 + 2)
}
} label: {
Image(systemName: "square.grid.3x3")
.font(.title2)
.foregroundColor(.primary)
}
}
}
}
}
}
private func adjustMagnification(from state: MagnificationGesture.Value) {
let stepCount = Int(min(max(1, state), 5))
// let delta = state / lastMagnifyBy
// magnifyBy *= delta
withAnimation(.linear) {
colums = Array(repeating: GridItem(), count: stepCount)
}
lastMagnifyBy = state
}
private func getMinMagnificationAllowed() -> CGFloat {
max(magnifyBy, minMagnifyBy)
}
private func getMaxMagnificationAllowed() -> CGFloat {
min(magnifyBy, maxMagnifyBy)
}
private func validateMagnificationLimits() {
magnifyBy = getMinMagnificationAllowed()
magnifyBy = getMaxMagnificationAllowed()
}
}
Here you go. This uses a TrackableScrollView (git link in the code).
I implemented an array of possible zoomStages (cols per row), to make switching between them easier.
Next to dos would be scrolling back to the magnification center, so the same item stays in focus. And maybe an opacity transition in stead of rearranging the Grid. Have fun ;)
import SwiftUI
// https://github.com/maxnatchanon/trackable-scroll-view.git
import SwiftUITrackableScrollView
struct ContentView: View {
let colors: [Color] = [.red, .purple, .yellow, .green, .blue, .mint, .orange]
let zoomStages = [1, 3, 5, 9, 15]
#State private var zoomStageIndex = 0
var colums: [GridItem] { Array(repeating: GridItem(spacing: 0), count: zoomStages[zoomStageIndex]) }
#State var magnifyBy: CGFloat = 1.0
#State private var scrollViewOffset = CGFloat.zero // SwiftUITrackableScrollView: Content offset available to use
var body: some View {
NavigationView {
TrackableScrollView(.vertical, showIndicators: false, contentOffset: $scrollViewOffset) {
LazyVGrid(columns: colums, spacing: 0) {
ForEach(0..<500) { number in
colors[number % colors.count]
.overlay(
Text("\(number)").font(.title2.bold()).foregroundColor(.white)
.minimumScaleFactor(0.1)
)
.aspectRatio(1, contentMode: .fit) // always squares
.id(number)
}
}
.scaleEffect(magnifyBy, anchor: .top)
// offset to correct magnify "center" point
.offset(x: 0, y: (scrollViewOffset + UIScreen.main.bounds.midY) * (1 - magnifyBy) )
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation(.spring(response: 0.8)) {
if zoomStageIndex < zoomStages.count-1 {
zoomStageIndex += 1
} else {
zoomStageIndex = 0
}
}
} label: {
Image(systemName: "square.grid.3x3")
.font(.title2)
.foregroundColor(.primary)
}
}
}
.gesture(magnification)
}
.ignoresSafeArea()
}
}
var magnification: some Gesture {
MagnificationGesture()
.onChanged { state in
magnifyBy = state
}
.onEnded { state in
// find predefined zoom(index) that is closest to actual pinch zoom value
let newZoom = Double(zoomStages[zoomStageIndex]) * 1 / state
let newZoomIndex = findClosestZoomIndex(value: newZoom)
// print("***", zoomStages[zoomStageIndex], state, newZoom, newZoomIndex)
withAnimation(.spring(response: 0.8)) {
magnifyBy = 1 // reset scaleEffect
zoomStageIndex = newZoomIndex // set new zoom level
}
}
}
func findClosestZoomIndex(value: Double) -> Int {
let distanceArray = zoomStages.map { abs(Double($0) - value) } // absolute difference between zoom stages and actual pinch zoom
// print("dist:", distanceArray)
return distanceArray.indices.min(by: {distanceArray[$0] < distanceArray[$1]}) ?? 0 // return index of element that is "closest"
}
}

in SwiftUI, in a ForEach(0 ..< 3), animate the tapped button only (not all 3), animate the other 2 differently

After a full day trying to animate these buttons I give up and ask for help.
I would like to animate the correct button only like this:
.rotation3DEffect(.degrees(self.animationAmount), axis: (x: 0, y: 1, z: 0))
and at the same time make the other two buttons fade out to 25% opacity.
When the player clicks the wrong button I would like to
animate the wrong button like this:
.rotation3DEffect(.degrees(self.animationAmount), axis: (x: 1, y: 1, z: 1)) (or anyway else you can think to indicate disaster) and leave the other two alone.
After that happened I would like the alert to show.
Below is my code. I commented what I would like to do and where if at all possible.
It all works like I want but cannot get the animation going.
Thanks for your help in advance
import SwiftUI
struct ContentView: View {
#State private var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Nigeria", "Poland", "Russia", "Spain", "UK", "US"]
#State private var correctAnswer = Int.random(in: 0...2)
#State private var showingScore = false
#State private var scoreTitle = ""
#State private var userScore = 0
#State private var userTapped = ""
#State private var animationAmount = 0.0
var body: some View {
ZStack {
LinearGradient(gradient: Gradient(colors: [.blue, .black]), startPoint: .top, endPoint: .bottom)
.edgesIgnoringSafeArea(.all)
VStack(spacing: 20) {
VStack {
Text("Tap the flag of...")
.foregroundColor(.white).font(.title)
Text(countries[correctAnswer])
.foregroundColor(.white)
.font(.largeTitle).fontWeight(.black)
}
ForEach(0 ..< 3) { number in
Button(action: {
self.flagTapped(number)
if self.correctAnswer == number {
//animate the correct button only like this:
//.rotation3DEffect(.degrees(self.animationAmount), axis: (x: 0, y: 1, z: 0))
// and
// make the other two buttons fade out to 25% opacity
} else {
// animate the wrong button like this:
//.rotation3DEffect(.degrees(self.animationAmount), axis: (x: 1, y: 1, z: 1))
}
}) {
Image(self.countries[number])
.renderingMode(.original)
.clipShape(Capsule())
.overlay(Capsule().stroke(Color .black, lineWidth: 1))
.shadow(color: .black, radius: 2)
}
}
Text ("your score is:\n \(userScore)").foregroundColor(.white).font(.title).multilineTextAlignment(.center)
}
}
.alert(isPresented: $showingScore) {
Alert(title: Text(scoreTitle), message: Text("You chose the flag of \(userTapped)\nYour score is now: \(userScore)"), dismissButton: .default(Text("Continue")) {
self.askQuestion()
})
}
}
func flagTapped(_ number: Int) {
userTapped = countries[number]
if number == correctAnswer {
scoreTitle = "Correct"
userScore += 1
} else {
scoreTitle = "Wrong"
userScore -= 1
}
showingScore = true
}
func askQuestion() {
countries.shuffle()
correctAnswer = Int.random(in: 0...2)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I had a problem similar to yours when solving this challenge. I figured out a solution without using DispatchQueue.main.asyncAfter. I made the following as the final solution:
Spin around 360 degrees the correct chosen flag and fade out the other flags to 25 % of opacity.
Blur the background of the wrong flag chosen with red and fade out the other flags to 25 % of opacity.
Here is the full solution (I comment on the parts that were important to achieve the above solution):
import SwiftUI
// Create a custom view
struct FlagImage: View {
var countryFlags: String
var body: some View {
Image(countryFlags)
.renderingMode(.original)
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.black, lineWidth: 1))
.shadow(color: .black, radius: 2)
}
}
struct ContentView: View {
#State private var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Nigeria", "Poland", "Russia", "Spain", "UK", "US"].shuffled()
#State private var correctAnswer = Int.random(in: 0...3)
#State private var showingScore = false
#State private var scoreTitle = ""
#State private var userScore = 0
// Properties for animating the chosen flag
#State private var animateCorrect = 0.0
#State private var animateOpacity = 1.0
#State private var besidesTheCorrect = false
#State private var besidesTheWrong = false
#State private var selectedFlag = 0
var body: some View {
ZStack {
LinearGradient(gradient: Gradient(colors: [.blue, .black]), startPoint: .top, endPoint: .bottom).edgesIgnoringSafeArea(.all)
VStack(spacing: 30) {
VStack {
Text("Tap on the flag!")
.foregroundColor(.white)
.font(.title)
Text(countries[correctAnswer])
.foregroundColor(.white)
.font(.largeTitle)
.fontWeight(.black)
}
ForEach(0 ..< 4) { number in
Button(action: {
self.selectedFlag = number
self.flagTapped(number)
}) {
FlagImage(countryFlags: self.countries[number])
}
// Animate the flag when the user tap the correct one:
// Rotate the correct flag
.rotation3DEffect(.degrees(number == self.correctAnswer ? self.animateCorrect : 0), axis: (x: 0, y: 1, z: 0))
// Reduce opacity of the other flags to 25%
.opacity(number != self.correctAnswer && self.besidesTheCorrect ? self.animateOpacity : 1)
// Animate the flag when the user tap the wrong one:
// Create a red background to the wrong flag
.background(self.besidesTheWrong && self.selectedFlag == number ? Capsule(style: .circular).fill(Color.red).blur(radius: 30) : Capsule(style: .circular).fill(Color.clear).blur(radius: 0))
// Reduce opacity of the other flags to 25% (including the correct one)
.opacity(self.besidesTheWrong && self.selectedFlag != number ? self.animateOpacity : 1)
}
Spacer()
Text("Your total score is: \(userScore)")
.foregroundColor(Color.white)
.font(.title)
.fontWeight(.black)
Spacer()
}
}
.alert(isPresented: $showingScore) {
Alert(title: Text(scoreTitle), dismissButton: .default(Text("Continue")) {
self.askQuestion()
})
}
}
func flagTapped(_ number: Int) {
if number == correctAnswer {
scoreTitle = "Correct!"
userScore += 1
// Create animation for the correct answer
withAnimation {
self.animateCorrect += 360
self.animateOpacity = 0.25
self.besidesTheCorrect = true
}
} else {
scoreTitle = "Wrong!"
// Create animation for the wrong answer
withAnimation {
self.animateOpacity = 0.25
self.besidesTheWrong = true
}
}
showingScore = true
}
func askQuestion() {
// Return the booleans to false
besidesTheCorrect = false
besidesTheWrong = false
countries = countries.shuffled()
correctAnswer = Int.random(in: 0...3)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Basically, my solution is to add ternary operators inside the view modifiers (e.g., .rotation3DEffect(...), .opacity(...) and .background(...)) after the Button view. The tricky part is to correctly combine the checking condition.
I prefer to add the withAnimation modifier to my flagTapped function. In this place I have more control of the animations if the user select a correct or a wrong flag.
I made a small change to the original challenge: just add one more flag to the view.
The final result when a user presses the correct and wrong flag is here:
.
I had the same problem. Here is my solution (only the code that's different from yours):
The ForEach:
ForEach(0 ..< 3, id: \.self){ number in
Button(action: {
withAnimation{
self.tappedFlag = number
self.flagTapped(number)
}
}){
FlagImage(imageName: self.countries[number])
}
.rotation3DEffect(.degrees(self.isCorrect && self.selcectedNumber == number ? 360 : 0), axis: (x: 0, y: 1, z: 0))
.opacity(self.isFadeOutOpacity && self.selcectedNumber != number ? 0.25 : 1)
.rotation3DEffect(.degrees(self.wrongAnswer && number != self.correctAnswer ? 180 : 0), axis: (x: 1, y: 0, z: 0))
.opacity(self.wrongAnswer && number != self.correctAnswer ? 0.25 : 1)
}
The alert:
.alert(isPresented: $showingScore){
if scoreTitle == "Correct"{
return Alert(title: Text(scoreTitle), message: Text("Your score is \(userScore)"), dismissButton: .default(Text("Continue")){
self.askQuestion()
})
}else{
return Alert(title: Text(scoreTitle), message: Text("That is the flag of \(countries[tappedFlag]), you lost one point!"), dismissButton: .default(Text("Continue")){
self.askQuestion()
})
}
}
The two functions:
func flagTapped(_ number: Int){
self.selcectedNumber = number
self.alreadyTapped = true
if number == correctAnswer{
scoreTitle = "Correct"
userScore += 1
self.isCorrect = true
self.isFadeOutOpacity = true
}else{
self.wrongAnswer = true
scoreTitle = "Wrong"
if userScore != 0{
userScore -= 1
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.showingScore = true
}
}
func askQuestion(){
countries = countries.shuffled()
correctAnswer = Int.random(in: 0...2)
self.isCorrect = false
self.isFadeOutOpacity = false
self.wrongAnswer = false
}
You have to declare some new variables :)
I hope I could help.
PS: There is a playlist on Youtube for 100DaysOfSwiftUI with the solutions to almost every own task.
https://www.youtube.com/watch?v=9AUGceRIUSA&list=PL3pUvT0fmHNhb3qcpvuym6KeM12eQK3T1