SwiftUI WatchOS: Animation behaves strangely when placed inside a list? - swift

I have this pulse animation below which works well by itself, but when I place it inside of a List then the animation of the circles pulsing is correct but all of the circles also move vertically from the top to the center of the screen as well? Outside of a list the circles remain in the center. Why is a list causing this and how to get around?
import SwiftUI
struct HeartRatePulseView: View {
#State var animate = false
#Environment(\.scenePhase) private var scenePhase
func circlesColor() -> Color {
Color.blue
}
var body: some View {
VStack(spacing: -3) {
ZStack {
ZStack {
GeometryReader { geometry in
ZStack {
Circle().fill(circlesColor().opacity(0.25)).frame(width: geometry.size.width, height: geometry.size.height).scaleEffect(self.animate ? 1 : 0.01)
Circle().fill(circlesColor().opacity(0.35)).frame(width: geometry.size.width * 0.79, height: geometry.size.height * 0.79).scaleEffect(self.animate ? 1 : 0.01)
Circle().fill(circlesColor()).frame(width: geometry.size.width * 0.60, height: geometry.size.height * 0.60)
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
.onAppear { self.animate = true }
.onChange(of: scenePhase, perform: { newValue in
if newValue == .active {
self.animate = true
} else {
self.animate = false
}
})
.animation(animate ? Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true) : .default)
}
}
.frame(height: 145)
}
}
struct HeartRatePulseView_Previews: PreviewProvider {
static var previews: some View {
List {
HeartRatePulseView()
}
.listStyle(.carousel)
}
}

Ok, if you change the animation method slightly it works,
Note the value argument, it seems this is the new way to animate
.animation(animate ? Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true) : nil, value: animate)
import SwiftUI
struct HeartRatePulseView: View {
#State var animate = false
#Environment(\.scenePhase) private var scenePhase
func circlesColor() -> Color {
Color.blue
}
var body: some View {
VStack(spacing: -3) {
ZStack {
ZStack {
GeometryReader { geometry in
ZStack {
Circle().fill(circlesColor().opacity(0.25)).frame(width: geometry.size.width, height: geometry.size.height).scaleEffect(self.animate ? 1 : 0.01)
Circle().fill(circlesColor().opacity(0.35)).frame(width: geometry.size.width * 0.79, height: geometry.size.height * 0.79).scaleEffect(self.animate ? 1 : 0.01)
Circle().fill(circlesColor()).frame(width: geometry.size.width * 0.60, height: geometry.size.height * 0.60)
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
.onAppear { self.animate = true }
.onChange(of: scenePhase, perform: { newValue in
if newValue == .active {
self.animate = true
} else {
self.animate = false
}
})
.animation(animate ? Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true) : nil, value: animate)
}
}
.frame(height: 145)
}
}
struct HeartRatePulseView_Previews: PreviewProvider {
static var previews: some View {
List {
HeartRatePulseView()
HeartRatePulseView()
}
.listStyle(.carousel)
}
}

Related

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
}

SwiftUI Custom View repeat forever animation show as unexpected

I created a custom LoadingView as a Indicator for loading objects from internet. When add it to NavigationView, it shows like this
enter image description here
I only want it showing in the middle of screen rather than move from top left corner
Here is my Code
struct LoadingView: View {
#State private var isLoading = false
var body: some View {
Circle()
.trim(from: 0, to: 0.8)
.stroke(Color.primaryDota, lineWidth: 5)
.frame(width: 30, height: 30)
.rotationEffect(Angle(degrees: isLoading ? 360 : 0))
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
self.isLoading.toggle()
}
}
}
}
and my content view
struct ContentView: View {
var body: some View {
NavigationView {
LoadingView()
.frame(width: 30, height: 30)
}
}
}
This looks like a bug of NavigationView: without it animation works totally fine. And it wan't fixed in iOS15.
Working solution is waiting one layout cycle using DispatchQueue.main.async before string animation:
struct LoadingView: View {
#State private var isLoading = false
var body: some View {
Circle()
.trim(from: 0, to: 0.8)
.stroke(Color.red, lineWidth: 5)
.frame(width: 30, height: 30)
.rotationEffect(Angle(degrees: isLoading ? 360 : 0))
.onAppear {
DispatchQueue.main.async {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
self.isLoading.toggle()
}
}
}
}
}
This is a bug from NavigationView, I tried to kill all possible animation but NavigationView ignored all my try, NavigationView add an internal animation to children! here all we can do right now!
struct ContentView: View {
var body: some View {
NavigationView {
LoadingView()
}
}
}
struct LoadingView: View {
#State private var isLoading: Bool = Bool()
var body: some View {
Circle()
.trim(from: 0, to: 0.8)
.stroke(Color.blue, lineWidth: 5.0)
.frame(width: 30, height: 30)
.rotationEffect(Angle(degrees: isLoading ? 360 : 0))
.animation(Animation.linear(duration: 1).repeatForever(autoreverses: false), value: isLoading)
.onAppear { DispatchQueue.main.async { isLoading.toggle() } }
}
}

Slide a Scrollview over another view

Here is what i am trying to achieve :
Here is what i currently have :
I cannot seem to get the my ScrollView to slide over the green header view (schedulerHeaderView) like in the first example. Here is my code
struct CreateShiftView: View {
#EnvironmentObject private var createShiftViewModel: CreateShiftViewModel
var body: some View {
GeometryReader { geo in
VStack {
schedulerHeaderView
Spacer()
}
.background(
Color(createShiftViewModel.isNavbarTitleHidden ? ThemeManager.current().greenery : .white)
.ignoresSafeArea(.all, edges: .top)
)
.overlay(
ScrollView {
VStack(spacing: 42) {
addEmployeeSection
shiftInfoSection
addBreakSection
addResourceSection
addJustificationSection
savePublishSection
}
.background(Color.white)
.cornerRadius(20, corners: [.topLeft, .topRight])
.padding(.horizontal, 24)
}
.frame(minWidth: UIScreen.main.bounds.width)
.offset(y: geo.size.height * 0.155)
)
}
}
}
I'm guessing i need to dynamically change the offset or position of the scrollView with a DragGesture but i haven't been able to get a working example so far. Any help would be appreciated. Thanks in advance.
I have found the solution to my question. In summary, i need to keep track of the last offset (using state) and use DragGesture's .onChanged and .onEnded to set a new offset depending on the value of the drag translation. Here is my updated code which now works
struct CreateShiftView: View {
#EnvironmentObject private var createShiftViewModel: CreateShiftViewModel
#State private var offsets = (top: CGFloat.zero, bottom: CGFloat.zero)
#State private var offset: CGFloat = .zero
#State private var lastOffset: CGFloat = .zero
#State private var isAtTopOffset = false
#State private var dragging = false
var body: some View {
GeometryReader { geo in
VStack {
schedulerHeaderView
Spacer()
}
.background(
Color(createShiftViewModel.isNavbarTitleHidden ? ThemeManager.current().greenery : .white)
.ignoresSafeArea(.all, edges: .top)
)
.overlay(
SlideOverView(cardviewInitialPosition: geo.size.height * 0.30, viewModel: createShiftViewModel) {
VStack(spacing: 42) {
informationSection
shiftInfoSection
addEmployeeSection
addBreakSection
addResourceSection
addJustificationSection
savePublishSection
}
.background(Color.white)
.cornerRadius(20, corners: [.topLeft, .topRight])
.padding(.horizontal, 24)
}
.onAppear {
self.offsets = (
top: .zero,
bottom: geo.size.height * 0.155
)
self.offset = self.offsets.bottom
self.lastOffset = self.offset
}
.offset(y: self.offset)
.animation(dragging ? nil : {
Animation.interpolatingSpring(stiffness: 250.0, damping: 40.0, initialVelocity: 5.0)
}())
.simultaneousGesture(
DragGesture(minimumDistance: createShiftViewModel.isNavbarTitleHidden ? 5 : 100, coordinateSpace: .local)
.onChanged { v in
dragging = true
let newOffset = self.lastOffset + v.translation.height
if (newOffset > self.offsets.top && newOffset < self.offsets.bottom) {
self.offset = newOffset
}
}
.onEnded{ v in
dragging = false
if (self.lastOffset == self.offsets.top && v.translation.height > 0) {
self.offset = self.offsets.bottom
createShiftViewModel.cardAtBottom()
} else if (self.lastOffset == self.offsets.bottom && v.translation.height < 0) {
self.offset = self.offsets.top
createShiftViewModel.cardAtTop()
}
self.lastOffset = self.offset
}
)
)
}
}
}

SwiftUI - How to limit the scope of animation to only the onAppear Transition

I'm new to SwiftUI and working through some sample projects to get the hang of it and I'm getting stuck on limiting the scope of the animation I set for the .transition for an AnimationModifier so it only impacts the animation of the transition and nothing else in the view.
While the separate transitions are respected for onAppear() and another for onDisappear(). The animation in the AnimatableModifier is overriding the removal over the item from the grid even when explicitly declared
I've tried explcicitly setting the Animation to the .offset transition in both the AnimatableModifier and for the CardView in the GameView, and when I do, no animation is triggered at all:
.transition(AnyTransition.offset(CGSize.init(width: randomXLocation, height: -offset.height-50)).animation(Animation.easeInOut(duration: 1.25).delay(delay)))
So, there's gotta be a way to limit the scope or explicitly declare the animation for transition or two separate Animations in the Animation Modifier, but I'm not finding any resources on how to move forward.
GameView.swift
struct GameView: View {
#ObservedObject var viewModel: SetGameViewModel
#State var delay: Double = 0.1
var body: some View {
GeometryReader { geometry in
VStack {
Grid(newItems: self.viewModel.newCards,
items: self.viewModel.cards.itemsAtWithIds(ids: self.viewModel.idOfCardsToDisplay)) { card in
CardView(card: card, bodyGeoProxy: geometry, delay: self.delay).onTapGesture {
self.viewModel.choose(card: card)
}
.transition(AnyTransition.offset(CGSize.init(width: randomXLocation, height: -offset.height-50)))
.animation(Animation.easeInOut(duration: 1.25).delay(delay))
.onAppear() {
let maxDelay: Double = Double(self.viewModel.cards.itemsAtWithIds(ids: self.viewModel.idOfCardsToDisplay).count)*0.2 + 0.2
if self.delay < 2.5 {
self.delay = self.delay + 0.2
} else if self.delay >= maxDelay {
self.delay = 0.1
}
}
}
HStack{
Button(action: {
self.viewModel.dealThreeCards()
}) {
Text("Hit Me")
}
Spacer()
Text("Score: \(self.viewModel.score)")
Spacer()
Button(action: {
self.viewModel.dealThreeCards()
}) {
Text("New Game")
}
}
}
}
}
}
GameView.swift
struct CardView: View{
var card: SetGame<SoloSetCardContent>.Card
var bodyGeoProxy: GeometryProxy
var delay: Double
var body: some View {
GeometryReader { geometry in
self.body(for: geometry)
}
}
init(card: SetGame<SoloSetCardContent>.Card, bodyGeoProxy: GeometryProxy, delay: Double) {
self.card = card
self.bodyGeoProxy = bodyGeoProxy
self.delay = delay
}
#ViewBuilder
func body(for geometryProxy: GeometryProxy) -> some View {
ZStack {
if card.isSelected {
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray)
.frame(width: geometryProxy.size.width-4, height: geometryProxy.size.height-4, alignment: .center)
.border(Color.blue, width: 2)
.animation(nil)
} else {
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray)
.frame(width: geometryProxy.size.width-4, height: geometryProxy.size.height-4, alignment: .center)
.border(Color.red, width: 2)
.animation(nil)
}
VStack {
ForEach(0..<self.card.content.deckShapes.count) { index in
VStack {
Spacer(minLength: 5)
ShapeView(setShape: self.card.content.deckShapes[index])
.frame(width: (geometryProxy.size.width-geometryProxy.size.width/5), height: geometryProxy.size.height/5, alignment: .center)
Spacer(minLength: 5)
}
}
}
}
.deal(delay: self.delay, offset: bodyGeoProxy.size)
}
}
Dealer.Swift - AnimatableModifier
struct Dealer: AnimatableModifier {
#State var show: Bool = false
var delay: Double
var offset: CGSize
var randomXLocation: CGFloat {
CGFloat.random(in: -offset.width ..< offset.width)
}
func body(content: Content) -> some View {
ZStack {
if show {
content
.transition(AnyTransition.offset(CGSize.init(width: randomXLocation, height: -offset.height-450)))
.animation(Animation.easeInOut(duration: 1.25).delay(delay))
}
}
.onAppear {
withAnimation {
self.show = true
}
}
.onDisappear {
withAnimation {
self.show = false
}
}
}
}
extension View {
func deal(delay: Double, offset: CGSize) -> some View {
self.modifier(Dealer(delay: delay, offset: offset))
}
}
I was able to resolve this by removing the Animation from the body content (and elsewhere) and adding to withAnimation portion of the .onAppear method in body function of the AnimationModifier
func body(content: Content) -> some View {
ZStack {
if show {
content
.transition(.asymmetric(insertion: .offset(CGSize.init(width: randomXLocation, height: -offset.height-50)),
removal: .offset(CGSize.init(width: randomXLocation, height: offset.height+50))))
}
}
.onDisappear {
withAnimation (Animation.easeInOut(duration: 1.25).delay(0)) {
self.show = false
}
}
.onAppear {
withAnimation (Animation.easeInOut(duration: 1.25).delay(self.delay)) {
self.show = true
}
}
}

SwiftUI list animations

I am following Apple's Swift UI Animating Views And Transitions and I noticed a bug in the Hike Graph View. When I click on the graph it does not allow me to switch from Elevation to Heart Rate or Pace. It does not let me and just exits the view. I think this has something to do with the List here:
VStack(alignment: .leading) {
Text("Recent Hikes")
.font(.headline)
HikeView(hike: hikeData[0])
}
Hike View Contains:
import SwiftUI
struct HikeView: View {
var hike: Hike
#State private var showDetail = false
var transition: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
.animation(nil)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}
if showDetail {
HikeDetail(hike: hike)
.transition(transition)
}
}
}
}
Hike Detail Contains:
struct HikeDetail: View {
let hike: Hike
#State var dataToShow = \Hike.Observation.elevation
var buttons = [
("Elevation", \Hike.Observation.elevation),
("Heart Rate", \Hike.Observation.heartRate),
("Pace", \Hike.Observation.pace),
]
var body: some View {
return VStack {
HikeGraph(hike: hike, path: dataToShow)
.frame(height: 200)
HStack(spacing: 25) {
ForEach(buttons, id: \.0) { value in
Button(action: {
self.dataToShow = value.1
}) {
Text(value.0)
.font(.system(size: 15))
.foregroundColor(value.1 == self.dataToShow
? Color.gray
: Color.accentColor)
.animation(nil)
}
}
}
}
}
}
Hike Graoh Contains:
import SwiftUI
func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
where C.Element == Range<Double> {
guard !ranges.isEmpty else { return 0..<0 }
let low = ranges.lazy.map { $0.lowerBound }.min()!
let high = ranges.lazy.map { $0.upperBound }.max()!
return low..<high
}
func magnitude(of range: Range<Double>) -> Double {
return range.upperBound - range.lowerBound
}
extension Animation {
static func ripple(index: Int) -> Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
.delay(0.03 * Double(index))
}
}
struct HikeGraph: View {
var hike: Hike
var path: KeyPath<Hike.Observation, Range<Double>>
var color: Color {
switch path {
case \.elevation:
return .gray
case \.heartRate:
return Color(hue: 0, saturation: 0.5, brightness: 0.7)
case \.pace:
return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
default:
return .black
}
}
var body: some View {
let data = hike.observations
let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: self.path] })
let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
let heightRatio = (1 - CGFloat(maxMagnitude / magnitude(of: overallRange))) / 2
return GeometryReader { proxy in
HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
ForEach(data.indices) { index in
GraphCapsule(
index: index,
height: proxy.size.height,
range: data[index][keyPath: self.path],
overallRange: overallRange)
.colorMultiply(self.color)
.transition(.slide)
.animation(.ripple(index: index))
}
.offset(x: 0, y: proxy.size.height * heightRatio)
}
}
}
}
Graph Capsule Contains:
import SwiftUI
struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>
var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}
var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}
var body: some View {
Capsule()
.fill(Color.white)
.frame(height: height * heightRatio)
.offset(x: 0, y: height * -offsetRatio)
}
}
Is there any way to fix this? Thanks
The problem might be deeper in SwiftUI - if you comment out transition(.slide) in HikeGraph (and restart the XCODE), it will start working