SwiftUI sheet reappears after dismissed on Apple Watch - ios

I'm developing a fitness app for the Apple Watch that lets you choose an intensity before starting a workout. I want to present the intensity picker before the workout starts, so I tried presenting it as a sheet before navigating to the actual workout view. The problem is that when I try to dismiss the sheet, it is dismissed but it comes right back. I'm using Xcode 14.2 and watchOS 9.1.
This is the main view and also the view that presents the said sheet (the first one, controlled by showingZonePickerView):
import SwiftUI
#main
struct Wise_Watch_AppApp: App {
#StateObject private var workoutManager = WorkoutManager()
#SceneBuilder var body: some Scene {
WindowGroup {
NavigationView {
WorkoutView()
}
.sheet(isPresented: $workoutManager.showingZonePickerView, onDismiss: {
workoutManager.showingZonePickerView = false
}) {
WorkoutLevelPickerView(total: 5, completed: 1)
.toolbar(.hidden)
}
.sheet(isPresented: $workoutManager.showingSummaryView) {
SummaryView()
}
.environmentObject(workoutManager)
}
}
}
This is the view where the user can pick the preferred workout:
import SwiftUI
import HealthKit
struct WorkoutView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#State var linkActive: Bool = false
var workoutTypes: [HKWorkoutActivityType] = [.cycling, .running, .walking]
var workoutDictionary: Dictionary<String, HKWorkoutActivityType> = [
"figure.outdoor.cycle" : .cycling,
"figure.run" : .running,
"figure.walk" : .walking
]
var body: some View {
List(Array(workoutDictionary.keys), id: \.self) { workoutType in
NavigationLink(
destination: SessionPagingView(),
tag: workoutDictionary[workoutType]!,
selection: $workoutManager.selectedWorkout
) {
Label("\(workoutDictionary[workoutType]!.name)", systemImage: workoutType)
.font(.subheadline)
.fontWeight(.semibold)
.padding()
}
.padding(EdgeInsets(top: 15, leading: 5, bottom: 15, trailing: 5))
}
.listStyle(.carousel)
.navigationBarTitle("Workouts")
.onAppear {
workoutManager.requestAuthorization()
}
}
}
This is the view to which the app navigates when a NavigationLink is pressed:
import SwiftUI
import WatchKit
import ConfettiSwiftUI
struct SessionPagingView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#State private var selection: Tab = .metrics
#State private var counter: Int = 0
#State private var isViewHidden: Bool = true
enum Tab {
case metrics, nowPlaying, milestone
}
var body: some View {
if(isViewHidden) {
sessionView.hidden()
} else {
sessionView
}
}
var sessionView: some View {
TabView(selection: $selection) {
ForEach(workoutManager.tabItems) { item in
VStack {
Text("Congrats!")
.font(.title2)
.fontWeight(.bold)
.confettiCannon(counter: $counter, num: 40, radius: 200)
Text("You just reached")
.font(.subheadline)
.foregroundColor(.cyan)
.tag(Tab.milestone)
Text("\(item.value) km")
.font(.subheadline)
.foregroundColor(.cyan)
.tag(Tab.milestone)
}
}
MetricsView().tag(Tab.metrics)
NowPlayingView().tag(Tab.nowPlaying)
}
.navigationBarHidden(true)
.navigationBarBackButtonHidden(true)
.onAppear {
workoutManager.showingZonePickerView = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.isViewHidden = false
}
}
.onChange(of: workoutManager.running) { _ in
displayMetricsView()
}
.onChange(of: workoutManager.tabItems) { _ in
if(workoutManager.tabItems.count > 0) {
displayMilestoneView()
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5), execute: {
displayMetricsView()
})
}
}
.ignoresSafeArea()
.animation(.easeInOut, value: self.selection)
}
private func displayMetricsView() {
withAnimation {
selection = .metrics
}
}
private func displayMilestoneView() {
withAnimation {
selection = .milestone
WKInterfaceDevice.current().play(.notification)
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
counter += 1
})
}
}
}
This is the actual view that I'm presenting inside the sheet:
import SwiftUI
struct WorkoutLevelPickerView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#Environment(\.dismiss) private var dismiss
let total: Int
#State var lineWidth: CGFloat = 16
#State var color: Color = .green
#State var completed: Double = 1.0
#State var currentZone: HeartRateZone = zones[0]
#State var isScrolling: Bool = false
var body: some View {
VStack {
ZStack {
CircleLabelView(
radius: 30,
tracking: 0,
size: .init(width: 120, height: 120),
text: currentZone.intensity.uppercased()
)
.font(.headline)
.frame(width: 50, height: 50)
.rotationEffect(Angle(degrees: Double(currentZone.labelRotationAngleModifier * currentZone.intensity.count / 2)))
.opacity(isScrolling ? 0 : 1)
.animation(.easeInOut, value: isScrolling)
WorkoutLevelPickerBackgroundView(total: total, lineWidth: lineWidth)
withAnimation(.spring()) {
WorkoutLevelPickerProgressView(total: total, completed: Int(completed), lineWidth: lineWidth, zone: currentZone)
}
VStack {
Button {
dismiss()
} label: {
Image(systemName: currentZone.iconName)
.padding()
.font(.title2)
}
.frame(width: 70, height: 70)
}
}
.frame(width: 150, height: 150)
.focusable()
.digitalCrownRotation(
detent: $completed.animation(.spring()),
from: 1.0,
through: 5.0,
by: 1.0,
sensitivity: .low,
isContinuous: false,
isHapticFeedbackEnabled: true,
onChange: { _ in
isScrolling = true
},
onIdle: {
isScrolling = false
}
)
.digitalCrownAccessory(.hidden)
.onChange(of: completed) {_ in
if(Int(completed) != currentZone.id) {
currentZone = zones[Int(completed) - 1]
print(currentZone.tint.description)
}
}
}
}
}
This is a video of the flow that generates my problem:
I tried presenting the sheet from other views. I also tried dismissing the sheet through a binding, not through the dismiss action. On all these changes, the outcome was the same as before.
Edit:
Forgot to mention that, when the sheet is presented, pressing the side button will dismiss it and the app will work as intended until the user tries to start a new workout.

As Paulw11 mentioned in his comment, the problem was that I was constantly setting showingZonePickerView on true when the sheet was dismissed and the view reappeared.

Related

How to make a view's height animate from 0 to height in SwiftUI?

I'd like to make this bar's height (part of a larger bar graph) animate on appear from 0 to its given height, but I can't quite figure it out in SwiftUI? I found this code that was helpful, but with this only the first bar (of 7) animates:
struct BarChartItem: View {
var value: Int
var dayText: String
var topOfSCaleColorIsRed: Bool
#State var growMore: CGFloat = 0
#State var showMore: Double = 0
var body: some View {
VStack {
Text("\(value)%")
.font(Font.footnote.monospacedDigit())
RoundedRectangle(cornerRadius: 5.0)
.fill(getBarColor())
.frame(width: 40, height: growMore)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation(.linear(duration: 7.0)) {
growMore = CGFloat(value)
showMore = 1
}
}
}.opacity(showMore)
Text(dayText.uppercased())
.font(.caption)
}
}
And this is the chart:
HStack(alignment: .bottom) {
ForEach(pastRecoveryScoreObjects) { pastRecoveryScoreObject in
BarChartItem(value: pastRecoveryScoreObject.recoveryScore, dayText: "\(pastRecoveryScoreObject.date.weekdayName)", topOfSCaleColorIsRed: false)
}
}
This works: you can get the index of the ForEach loop and delay each bar's animation by some time * that index. All the bars are connected to the same state variable via a binding, and the state is set immediately on appear. You can play with the parameters to tune it to your own preferences.
struct TestChartAnimationView: View {
let values = [200, 120, 120, 90, 10, 80]
#State var open = false
var animationTime: Double = 0.25
var body: some View {
VStack {
Spacer()
HStack(alignment: .bottom) {
ForEach(values.indices, id: \.self) { index in
BarChartItem(open: $open, value: values[index])
.animation(
Animation.linear(duration: animationTime).delay(animationTime * Double(index))
)
}
}
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
open = true
}
}
}
}
struct BarChartItem: View {
#Binding var open: Bool
var value: Int
var body: some View {
VStack {
Text("\(value)%")
.font(Font.footnote.monospacedDigit())
RoundedRectangle(cornerRadius: 5.0)
.fill(Color.blue)
.frame(width: 20, height: open ? CGFloat(value) : 0)
Text("Week".uppercased())
.font(.caption)
}
}
}
Edit: ForEach animations are kind of weird. I'm not sure why, but this works:
struct ContentView: View {
let values = [200, 120, 120, 90, 10, 80]
var body: some View {
HStack(alignment: .bottom) {
ForEach(values, id: \.self) { value in
BarChartItem(totalValue: value)
.animation(.linear(duration: 3))
}
}
}
}
struct BarChartItem: View {
#State var value: Int = 0
var totalValue = 0
var body: some View {
VStack {
Text("\(value)%")
.font(Font.footnote.monospacedDigit())
RoundedRectangle(cornerRadius: 5.0)
.fill(Color.blue)
.frame(width: 20, height: CGFloat(value))
Text("Week".uppercased())
.font(.caption)
}.onAppear {
value = totalValue
}
}
}
Instead of applying the animation in the onAppear, I applied an implicit animation inside the ForEach.
Result:

#Binding value change not being recognised in parent view

I have a connected a #Binding variable called showCodeMessage between my parent view LiveView and its child view NewTimer.
struct LiveView: View {
#ObservedObject var countdown: CountDown
#State var showCodeMessage: Bool = false {
didSet(val){
print("changed: \(val)") // doesn't print
}
}
var body: some View {
ZStack {
NewTimer(countdown: self.countdown, showCodeMessage: $showCodeMessage)
if self.showCodeMessage { // doesn't show
MessageWithButton()
.frame(minWidth: 0, maxWidth: .infinity, alignment: .bottom)
.padding(.top, 10)
.padding(20)
.opacity(self.messageOpacity2)
}
}
}
}
struct NewTimer: View {
#ObservedObject var countdown: CountDown
#Binding var showCodeMessage: Bool
#State var codeSent = false
#State var inBackground = false
var body: some View {
VStack{
Text("Timer")
.onAppear{
self.countdown.secondsLeft = 600
self.countdown.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true){ _ in
self.countdown.secondsLeft -= 1
// At the 9:00 minute mark, show code message
if self.countdown.secondsLeft <= 595 && !showCodeMessage && self.inBackground == false {
print("Show code message: \(self.countdown.secondsLeft)")
self.showCodeMessage = true
}
}
}
}
}
}
However, when I perform self.showCodeMessage = true - the change doesn't register in my parent view. It doesn't even detect that a change has been made.
Any idea why?
EDIT: Added other classes/structs below
class CountDown: ObservableObject {
init(secondsLeft: Int){
self.secondsLeft = secondsLeft
}
#Published var secondsLeft: Int
var timer: Timer?
func start(){
// Reset epoch
UserDefaults.standard.set(0, forKey: "epoch")
}
func stop(){
self.timer?.invalidate()
}
func performAtEnd(action: #escaping () -> Void){
self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true){ _ in
self.secondsLeft -= 1
print("performAtEnd() \(self.secondsLeft)")
if (self.secondsLeft == 0){
print("action() \(self.secondsLeft)")
action()
self.timer?.invalidate()
}
}
}
}
struct MessageWithButton: View {
let text: String
let color: Color
let hasButton: Bool = false
#Binding var showCode: Bool
var body: some View {
HStack{
Text("\(self.text)")
.frame(minWidth: 0, maxWidth: .infinity, alignment: .center)
.font(.custom("AvenirNext-Bold", size: 16))
.foregroundColor(.white)
.padding(11)
MessageButton(showCode: $showCode).padding(5)
}
.background(
RoundedRectangle(cornerRadius: 5)
.foregroundColor(self.color)
)
}
}
struct MessageButton : View {
#Binding var showCode: Bool
// #AppStorage("codeResult") var codeResult = UserDefaults.standard.string(forKey: "codeResult") ?? ""
#AppStorage("codeResult") var codeResult = UserDefaults.standard.string(forKey: "codeResult") ?? ""
var body: some View {
Button(action: {self.clickedCode()}){
Text("\(buttonText())")
.font(.custom("AvenirNext-Bold", size: 18))
.fontWeight(.bold)
.foregroundColor(Color.white)
.padding(10)
}
.background(Color("LightBlack"))
.cornerRadius(5)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(Color.black, lineWidth: 1)
)
.padding(.trailing, 5)
}
func buttonText() -> String {
if self.showCode { return "HIDE" }
return "ENTER"
}
func clickedCode(){
// Remove 'incorrect' from codeResult so Code input can show again
self.codeResult = ""
print("clickedcode()")
self.showCode.toggle()
}
}

Animation goes into background view instead of foreground

I have the following:
struct SettingsView: View {
#State private var showSuccessSave: Bool = false
var body: some View {
NavigationView {
ScrollView {
VStack {
SuccessNotificationView()
.offset(y: self.showSuccessSave ? -UIScreen.main.bounds.height/9 : -UIScreen.main.bounds.height)
.animation(
.interactiveSpring(
response: 0.8,
dampingFraction: 0.8,
blendDuration: 1
)
)
.onTapGesture {
self.showSuccessSave = false
}
Button(action: {
self.showSuccessSave.toggle()
}) {
Text("Save")
}
}
}
.navigationBarTitle("Settings")
}
}
}
And the function I call:
struct SuccessNotificationView: View {
var body: some View {
Text("Success")
.padding()
.foregroundColor(Color.white)
.frame(width: UIScreen.main.bounds.width, height: 100)
.background(Color.green)
.cornerRadius(20)
}
}
When it pops up, look at the settings text:
How do I make that notification be on the foreground covering the Settings text? Right now it displays everything in that view on top of it.
Here is possible approach. Tested with Xcode 11.4 / iOS 13.4
struct SettingsView: View {
#State private var showSuccessSave: Bool = false
var body: some View {
ZStack(alignment: .top) {
NavigationView {
ScrollView {
VStack {
Button(action: {
self.showSuccessSave.toggle()
}) {
Text("Save")
}
}
}
.navigationBarTitle("Settings")
}
SuccessNotificationView()
.offset(y: self.showSuccessSave ? 0 : -UIScreen.main.bounds.height)
.animation(
.interactiveSpring(
response: 0.8,
dampingFraction: 0.8,
blendDuration: 1
)
)
.onTapGesture {
self.showSuccessSave = false
}
}
}
}

How to animate a number that in wrapped in a View with SwiftUI?

Let say i have a simple view, and a number INSIDE another view:
struct ContentView: View {
#State private var counter = 0
var body: some View {
VStack {
Button(action: { self.counter += 1 }) {
Text("Add 1")
}
NumberView(currentValue: counter)
}
}
}
struct NumberView: View {
var currentValue: Int
var body: some View {
VStack {
Text("I don't want to be animated")
Text(currentValue.description)
.font(.largeTitle)
.foregroundColor(Color.black)
.padding(.all)
}
}
}
For every click I want the number to scale up for a second and go back as normal. (Using scale effect). How can i animate this scaling for a second?
try this:
struct ContentView: View {
#State private var counter = 0
#State var scale = false
var body: some View {
VStack {
Button(action: {
self.counter += 1
withAnimation(Animation.easeInOut(duration: 0.5)) {
self.scale.toggle()
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { (timer) in
withAnimation(Animation.easeInOut(duration: 0.5)) {
self.scale.toggle()
}
}
}
}) {
Text("Add 1")
}
NumberView(currentValue: counter)
.scaleEffect(scale ? 1.5 : 1)
}
}
}
struct NumberView: View {
var currentValue: Int
var body: some View {
Text(currentValue.description)
.font(.largeTitle)
.foregroundColor(Color.black)
.padding(.all)
}
}
ok, here the new answer for your changed requirement
struct ContentView: View {
#State private var counter = 0
#State var scale : CGFloat = 1
var body: some View {
VStack {
Button(action: {
self.counter += 1
withAnimation(Animation.easeInOut(duration: 0.5)) {
self.scale = 1.5
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { (timer) in
withAnimation(Animation.easeInOut(duration: 0.5)) {
self.scale = 1
}
}
}
}) {
Text ("add")
}
NumberView(currentValue: counter, scale: self.scale)
}
}
}
struct NumberView: View {
var currentValue: Int
var scale : CGFloat
var body: some View {
VStack {
Text("I don't want to be animated")
Text(currentValue.description)
.font(.largeTitle)
.foregroundColor(Color.black)
.padding(.all)
.scaleEffect(self.scale)
}
}
}

Custom modal transitions in SwiftUI

I'm trying to recreate the iOS 11/12 App Store with SwiftUI.
Let's imagine the "story" is the view displayed when tapping on the card.
I've done the cards, but the problem I'm having now is how to do the animation done to display the "story".
As I'm not good at explaining, here you have a gif:
Gif 1
Gif 2
I've thought of making the whole card a PresentationLink, but the "story" is displayed as a modal, so it doesn't cover the whole screen and doesn't do the animation I want.
The most similar thing would be NavigationLink, but that then obliges me to add a NavigationView, and the card is displayed like another page.
I actually do not care whether its a PresentationLink or NavigationLink or whatever as long as it does the animation and displays the "story".
Thanks in advance.
My code:
Card.swift
struct Card: View {
var icon: UIImage = UIImage(named: "flappy")!
var cardTitle: String = "Welcome to \nCards!"
var cardSubtitle: String = ""
var itemTitle: String = "Flappy Bird"
var itemSubtitle: String = "Flap That!"
var cardCategory: String = ""
var textColor: UIColor = UIColor.white
var background: String = ""
var titleColor: Color = .black
var backgroundColor: Color = .white
var body: some View {
VStack {
if background != "" {
Image(background)
.resizable()
.frame(width: 380, height: 400)
.cornerRadius(20)
} else {
RoundedRectangle(cornerRadius: 20)
.frame(width: 400, height: 400)
.foregroundColor(backgroundColor)
}
VStack {
HStack {
VStack(alignment: .leading) {
if cardCategory != "" {
Text(verbatim: cardCategory.uppercased())
.font(.headline)
.fontWeight(.heavy)
.opacity(0.3)
.foregroundColor(titleColor)
//.opacity(1)
}
HStack {
Text(verbatim: cardTitle)
.font(.largeTitle)
.fontWeight(.heavy)
.lineLimit(3)
.foregroundColor(titleColor)
}
}
Spacer()
}.offset(y: -390)
.padding(.bottom, -390)
HStack {
if cardSubtitle != "" {
Text(verbatim: cardSubtitle)
.font(.system(size: 17))
.foregroundColor(titleColor)
}
Spacer()
}
.offset(y: -50)
.padding(.bottom, -50)
}
.padding(.leading)
}.padding(.leading).padding(.trailing)
}
}
So
Card(cardSubtitle: "Welcome to this library I made :p", cardCategory: "CONNECT", background: "flBackground", titleColor: .white)
displays:
SwiftUI doesn't do custom modal transitions right now, so we have to use a workaround.
One method that I could think of is to do the presentation yourself using a ZStack. The source frame could be obtained using a GeometryReader. Then, the destination shape could be controlled using frame and position modifiers.
In the beginning, the destination will be set to exactly match position and size of the source. Then immediately afterwards, the destination will be set to fullscreen size in an animation block.
struct ContentView: View {
#State var isPresenting = false
#State var isFullscreen = false
#State var sourceRect: CGRect? = nil
var body: some View {
ZStack {
GeometryReader { proxy in
Button(action: {
self.isFullscreen = false
self.isPresenting = true
self.sourceRect = proxy.frame(in: .global)
}) { ... }
}
if isPresenting {
GeometryReader { proxy in
ModalView()
.frame(
width: self.isFullscreen ? nil : self.sourceRect?.width ?? nil,
height: self.isFullscreen ? nil : self.sourceRect?.height ?? nil)
.position(
self.isFullscreen ? proxy.frame(in: .global).center :
self.sourceRect?.center ?? proxy.frame(in: .global).center)
.onAppear {
withAnimation {
self.isFullscreen = true
}
}
}
}
}
.edgesIgnoringSafeArea(.all)
}
}
extension CGRect {
var center : CGPoint {
return CGPoint(x:self.midX, y:self.midY)
}
}
SwiftUI in iOS/tvOS 14 and macOS 11 has matchedGeometryEffect(id:in:properties:anchor:isSource:) to animate view transitions between different hierarchies.
Link to Official Documentation
Here's a minimal example:
struct SomeView: View {
#State var isPresented = false
#Namespace var namespace
var body: some View {
VStack {
Button(action: {
withAnimation {
self.isPresented.toggle()
}
}) {
Text("Toggle")
}
SomeSourceContainer {
MatchedView()
.matchedGeometryEffect(id: "UniqueViewID", in: namespace, properties: .frame, isSource: !isPresented)
}
if isPresented {
SomeTargetContainer {
MatchedTargetView()
.matchedGeometryEffect(id: "UniqueViewID", in: namespace, properties: .frame, isSource: isPresented)
}
}
}
}
}

Resources