SwiftUI: path not animating on change of binding animation - swift

I'm trying to smoothly animate the change of the length of an arc using the .animate() method of a binding, but the animation does not occur - it just changes suddenly. Here's a minimal example - what am I doing wrong?
I'm using WatchOS.
import SwiftUI
struct TestAnimation: View {
#State var value: Float = 0.2
var body: some View {
VStack {
Arc(value: $value)
Button(action: { self.value += 0.1 }) {
Text("Increment")
}
}
}
}
struct Arc: View {
var value: Binding<Float>
var body: some View {
ArcShape(value: value.animation(.easeOut(duration:2)))
.stroke(lineWidth: 3)
}
}
struct ArcShape : Shape {
var value: Binding<Float>
func path(in rect: CGRect) -> Path {
var p = Path()
let arcDegrees: Double = max(360.0 * Double(value.wrappedValue), 2.0)
let endAngle = -90.0 + arcDegrees
p.addArc(center: CGPoint(x: rect.midX, y:rect.midY), radius: rect.height / 2, startAngle: .degrees(-90), endAngle: .degrees(endAngle), clockwise: false)
return p
}
}

We need animatable data in shape and actually do not need binding but animation directly on Arc.
Tested with Xcode 13.4 / watchOS 8.5
Here is main part of fixed code:
struct Arc: View {
var body: some View {
ArcShape(value: value) // << here !!
.stroke(lineWidth: 3)
.animation(.easeOut(duration:2), value: value)
}
// ...
struct ArcShape : Shape {
var animatableData: CGFloat {
get { value }
set { value = newValue }
}
// ...
Complete test module is here

Related

How can I have a custom Animator value function in Swift/SwiftUI?

I am using Shape animatableData to animate a random value for use, the value that get animated does not need a Shape or any other view modifier either or AnimatableModifier, I am using Shape just for accessing animatableData I could be get same result with AnimatableModifier as well but the idea is just accessing those sweet values, here my code:
struct ContentView: View {
#State private var value: CGFloat = .zero
var body: some View {
ZStack {
Button("update value") {
value += 10.0
}
BaseShape(value: value)
}
.frame(width: 300.0, height: 200.0)
.animation(Animation.easeInOut(duration: 5.0), value: value)
}
}
struct BaseShape: Shape {
var value: CGFloat
init(value: CGFloat) {
self.value = value
}
internal var animatableData: CGFloat {
get { return value }
set(newValue) { value = newValue }
}
func path(in rect: CGRect) -> Path {
print(value)
return Path()
}
}
With that said, I want build a function to put out the Shape that I do not need it, like this:
struct AnimatableType: Animatable {
var value: CGFloat
var animatableData: CGFloat {
get { return value }
set(newValue) {
value = newValue
}
}
}
func valueAnimator(value: CGFloat, animatedValue: #escaping (AnimatableType) -> Void) {
// Incoming value get processed ...
// Sending the animatedValue ...
animatedValue(AnimatableType(value: value))
}
And my use case goal is like this:
struct ContentView: View {
#State private var value: CGFloat = .zero
var body: some View {
ZStack {
Button("update value") {
value += 10.0
valueAnimator(value: value, animatedValue: { value in
print(value.animatableData)
})
}
}
.frame(width: 300.0, height: 200.0)
.animation(Animation.easeInOut(duration: 5.0), value: value)
}
}
Is it a way around to reach the goal, the use case goal with Swift? or SwiftUI?

GradientAnimation ONLY Works in AnimatableModifier, Other Simultaneous Animations Working Regardless (SwiftUI)

I'm having a lot of trouble narrowing down the underlying implementation that might cause things to work this way. I've tried tons of combinations of using #State vs initializing, overlays, whatever.
1.
The only thing that works is when it is in an AnimatableModifier.
2.
Other animations on the same view, and based on same value work regardless.
Visual Example
You can see the scale animation working fine, but only the bottom rectangle is animating the gradient
Reproducible Code
I got some of the original code here. They mention weird issues with containers, but it's not clear what is expected behavior and otherwise
Works with Previews
NOTE: if you're using previews, make sure to play or animations won't run at all
import SwiftUI
import UIKit
public struct AnimatableGradient: View, Animatable {
public let from: [Color]
public let to: [Color]
public var pct: CGFloat
public var animatableData: CGFloat {
get { pct }
set { pct = newValue }
}
private var current: [Color] {
zip(from, to).map { from, to in
from.mix(with: to, percent: pct)
}
}
public var body: some View {
LinearGradient(
gradient: Gradient(colors: current),
startPoint: UnitPoint(x: 0, y: 0),
endPoint: UnitPoint(x: 1, y: 1)
)
/// temporary to show that animating is working
.scaleEffect(1 - (0.2 * pct))
}
}
// MARK: AnimatableModifier
struct PassthroughModifier<Body: View>: AnimatableModifier {
var animatableData: CGFloat
let body: (CGFloat) -> Body
func body(content: Content) -> some View {
// also works to just pass body(animatableData)
content.overlay(body(animatableData))
}
}
// MARK: Content
fileprivate struct GradientExample: View, Animatable {
#State var pct: CGFloat = 0
private var base: some View {
Rectangle()
.fill(.black)
.frame(width: 200, height: 100)
}
private func gradient(pct: CGFloat) -> some View {
AnimatableGradient(from: [.orange, .red],
to: [.blue, .green],
pct: pct)
}
var body: some View {
VStack(alignment: .leading) {
base.overlay(
gradient(pct: pct)
)
base.modifier(
PassthroughModifier(animatableData: pct){ pct in
gradient(pct: pct)
}
)
}
.onAppear{
withAnimation(.linear(duration: 2.8).repeatForever(autoreverses: true)) {
self.pct = pct == 0 ? 1 : 0
}
}
}
}
// MARK: Preview
struct GradientPreview: PreviewProvider {
static var previews: some View {
GradientExample()
}
}
// MARK: Helpers
extension Color {
public var components: (r: Double, g: Double, b: Double, a: Double) {
/// passing through UIColor because things like `Color.red`
/// always return `nil` otherwise :/
let comps = UIColor(self).cgColor.components ?? []
return (
comps[safe: 0] ?? 0,
comps[safe: 1] ?? 0,
comps[safe: 2] ?? 0,
comps[safe: 3] ?? 0
)
}
}
extension Array {
subscript(safe idx: Int) -> Element? {
guard idx < count else { return nil }
return self[idx]
}
}
extension Color {
public func mix(with other: Color, percent: Double) -> Color {
let left = self.components
let right = other.components
let r = left.r + right.r - (left.r * percent)
let g = left.g + right.g - (left.g * percent)
let b = left.b + right.b - (left.b * percent)
return Color(red: Double(r), green: Double(g), blue: Double(b))
}
}

Limit rectangle to screen edge on drag gesture

I'm just getting started with SwiftUI and I was hoping for the best way to tackle the issue of keeping this rectangle in the bounds of a screen during a drag gesture. Right now it goes off the edge until it reaches the middle of the square (I think cause I'm using CGPoint).
I tried doing some math to limit the rectangle and it succeeds on the left side only but it seems like an awful way to go about this and doesn't account for varying screen sizes. Can anyone help?
struct ContentView: View {
#State private var pogPosition = CGPoint()
var body: some View {
PogSound()
.position(pogPosition)
.gesture(
DragGesture()
.onChanged { value in
self.pogPosition = value.location
// Poor solve
if(self.pogPosition.x < 36) {
self.pogPosition.x = 36
}
}
.onEnded { value in
print(value.location)
}
)
}
}
Here is a demo of possible approach (for any view, cause view frame is read dynamically).
Demo & tested with Xcode 12 / iOS 14
struct ViewSizeKey: PreferenceKey {
static var defaultValue = CGSize.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}
struct ContentView: View {
#State private var pogPosition = CGPoint()
#State private var size = CGSize.zero
var body: some View {
GeometryReader { gp in
PogSound()
.background(GeometryReader {
Color.clear
.preference(key: ViewSizeKey.self, value: $0.frame(in: .local).size)
})
.onPreferenceChange(ViewSizeKey.self) {
self.size = $0
}
.position(pogPosition)
.gesture(
DragGesture()
.onChanged { value in
let rect = gp.frame(in: .local)
.inset(by: UIEdgeInsets(top: size.height / 2.0, left: size.width / 2.0, bottom: size.height / 2.0, right: size.width / 2.0))
if rect.contains(value.location) {
self.pogPosition = value.location
}
}
.onEnded { value in
print(value.location)
}
)
.onAppear {
let rect = gp.frame(in: .local)
self.pogPosition = CGPoint(x: rect.midX, y: rect.midY)
}
}.edgesIgnoringSafeArea(.all)
}
}

How to animate shape via state variable in SwiftUI?

Trying to animate right side of rectangle in SwiftUI on tap. But it's not working(
It has ratio state var and it's of animatable type (CGFloat). Totally out of ideas why. Please advise.
struct ContentView: View {
#State private var animated = false
var body: some View {
Bar(ratio: animated ? 0.0 : 1.0).animation(Animation.easeIn(duration: 1))
.onTapGesture {
self.animated.toggle()
}.foregroundColor(.green)
}
}
struct Bar: Shape {
#State var ratio: CGFloat
var animatableData: CGFloat {
get { return ratio }
set { ratio = newValue }
}
func path(in rect: CGRect) -> Path {
var p = Path()
p.move(to: CGPoint.zero)
let width = rect.size.width * ratio
p.addLine(to: CGPoint(x: width, y: 0))
let height = rect.size.height
p.addLine(to: CGPoint(x: width, y: height))
p.addLine(to: CGPoint(x: 0, y: height))
p.closeSubpath()
return p
}
}
It is not needed #State for animatable data, so fix is simple
struct Bar: Shape {
var ratio: CGFloat
Tested with this demo view (on Xcode 11.2 / iOS 13.2)
struct TestAnimateBar: View {
#State private var animated = false
var body: some View {
VStack {
Bar(ratio: animated ? 0.0 : 1.0).animation(Animation.easeIn(duration: 1))
.foregroundColor(.green)
}
.background(Color.gray)
.frame(height: 40)
.onTapGesture {
self.animated.toggle()
}
}
}

SwiftUI Scroll/List Scrolling Events

lately I have been trying to create a pull to (refresh, load more) swiftUI Scroll View !!, inspired by https://cocoapods.org/pods/SwiftPullToRefresh
I was struggling to get the offset and the size of the content. but now I am struggling to get the event when the user releases the scroll view to finish the UI.
here is my current code:
struct PullToRefresh2: View {
#State var offset : CGPoint = .zero
#State var contentSize : CGSize = .zero
#State var scrollViewRect : CGRect = .zero
#State var items = (0 ..< 50).map { "Item \($0)" }
#State var isTopRefreshing = false
#State var isBottomRefreshing = false
var top : CGFloat {
return self.offset.y
}
private var bottomLocation : CGFloat {
if contentSize.height >= scrollViewRect.height {
return self.contentSize.height + self.top - self.scrollViewRect.height + 32
}
return top + 32
}
private var shouldTopRefresh : Bool {
return self.top > 80
}
private var shouldBottomRefresh : Bool {
return self.bottomLocation < -80 + 32
}
func watchOffset() -> Binding<CGPoint> {
return .init(get: {
return self.offset
},set: {
print("watched : offset= \($0)")
self.offset = $0
})
}
private func computeOffset() -> CGFloat {
if isTopRefreshing {
print("OFFSET: isTopRefreshing")
return 32
} else if isBottomRefreshing {
if (contentSize.height+32) < scrollViewRect.height {
print("OFFSET: isBottomRefreshing 1")
return top
} else if scrollViewRect.height > contentSize.height {
print("OFFSET: isBottomRefreshing 2")
return 32 - (scrollViewRect.height - contentSize.height)
} else {
print("OFFSET: isBottomRefreshing 3")
return scrollViewRect.height - contentSize.height - 32
}
}
print("OFFSET: fall back->\(top)")
return top
}
func watchScrollViewRect() -> Binding<CGRect> {
return .init(get: {
return self.scrollViewRect
},set: {
print("watched : scrollViewRect= \($0)")
self.scrollViewRect = $0
})
}
func watchContentSize() -> Binding<CGSize> {
return .init(get: {
return self.contentSize
},set: {
print("watched : contentSize= \($0)")
self.contentSize = $0
})
}
func newDragGuesture() -> some Gesture {
return DragGesture()
.onChanged { _ in
print("> drag changed")
}
.onEnded { _ in
DispatchQueue.main.async {
print("> drag ended")
self.isTopRefreshing = self.shouldTopRefresh
self.isBottomRefreshing = self.shouldTopRefresh
withAnimation {
self.offset = CGPoint.init(x: self.offset.x, y: self.computeOffset())
}
}
}
}
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack {
Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
Text("Back")
}
ZStack {
OffsetScrollView(.vertical, showsIndicators: true,
offset: self.watchOffset(),
contentSize: self.watchContentSize(),
scrollViewFrame: self.watchScrollViewRect())
{
VStack {
ForEach(self.items, id: \.self) { item in
HStack {
Text("\(item)")
.font(.system(Font.TextStyle.title))
.fontWeight(.regular)
//.frame(width: geo.size.width)
//.background(Color.blue)
.padding(.horizontal, 8)
Spacer()
}
//.background(Color.red)
.padding(.bottom, 8)
}
}//.background(Color.clear)
}.edgesIgnoringSafeArea(.horizontal)
.background(Color.red)
//.simultaneousGesture(self.newDragGuesture())
VStack {
ArrowShape()
.stroke(style: StrokeStyle.init(lineWidth: 2, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [], dashPhase: 0))
.fill(Color.black)
.frame(width: 12, height: 16)
.padding(.all, 2)
//.animation(nil)
.rotationEffect(.degrees(self.shouldTopRefresh ? -180 : 0))
.animation(.linear(duration: 0.2))
.transformEffect(.init(translationX: 0, y: self.top - 32))
.animation(nil)
.opacity(self.isTopRefreshing ? 0 : 1)
Spacer()
ArrowShape()
.stroke(style: StrokeStyle.init(lineWidth: 2, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [], dashPhase: 0))
.fill(Color.black)
.frame(width: 12, height: 16)
.padding(.all, 2)
//.animation(nil)
.rotationEffect(.degrees(self.shouldBottomRefresh ? 0 : -180))
.animation(.linear(duration: 0.2))
.transformEffect(.init(translationX: 0, y: self.bottomLocation))
.animation(nil)
.opacity(self.isBottomRefreshing ? 0 : 1)
}
// Color.init(.sRGB, white: 0.2, opacity: 0.7)
//
// .simultaneousGesture(self.newDragGuesture())
}
.clipped()
.clipShape(Rectangle())
Text("Offset: \(String(describing: self.offset))")
Text("contentSize: \(String(describing: self.contentSize))")
Text("scrollViewRect: \(String(describing: self.scrollViewRect))")
}
}
}
//https://zacwhite.com/2019/scrollview-content-offsets-swiftui/
public struct OffsetScrollView<Content>: View where Content : View {
/// The content of the scroll view.
public var content: Content
/// The scrollable axes.
///
/// The default is `.vertical`.
public var axes: Axis.Set
/// If true, the scroll view may indicate the scrollable component of
/// the content offset, in a way suitable for the platform.
///
/// The default is `true`.
public var showsIndicators: Bool
/// The initial offset of the view as measured in the global frame
#State private var initialOffset: CGPoint?
/// The offset of the scroll view updated as the scroll view scrolls
#Binding public var scrollViewFrame: CGRect
#Binding public var offset: CGPoint
#Binding public var contentSize: CGSize
public init(_ axes: Axis.Set = .vertical,
showsIndicators: Bool = true,
offset: Binding<CGPoint> = .constant(.zero),
contentSize: Binding<CGSize> = .constant(.zero) ,
scrollViewFrame: Binding<CGRect> = .constant(.zero),
#ViewBuilder content: () -> Content) {
self.axes = axes
self.showsIndicators = showsIndicators
self._offset = offset
self._contentSize = contentSize
self.content = content()
self._scrollViewFrame = scrollViewFrame
}
public var body: some View {
ZStack {
GeometryReader { geometry in
Run {
let frame = geometry.frame(in: .global)
self.$scrollViewFrame.wrappedValue = frame
}
}
ScrollView(axes, showsIndicators: showsIndicators) {
ZStack(alignment: .leading) {
GeometryReader { geometry in
Run {
let frame = geometry.frame(in: .global)
let globalOrigin = frame.origin
self.initialOffset = self.initialOffset ?? globalOrigin
let initialOffset = (self.initialOffset ?? .zero)
let offset = CGPoint(x: globalOrigin.x - initialOffset.x, y: globalOrigin.y - initialOffset.y)
self.$offset.wrappedValue = offset
self.$contentSize.wrappedValue = frame.size
}
}
content
}
}
}
}
}
struct Run: View {
let block: () -> Void
var body: some View {
DispatchQueue.main.async(execute: block)
return AnyView(EmptyView())
}
}
extension CGPoint {
func reScale(from: CGRect, to: CGRect) -> CGPoint {
let x = (self.x - from.origin.x) / from.size.width * to.size.width + to.origin.x
let y = (self.y - from.origin.y) / from.size.height * to.size.height + to.origin.y
return .init(x: x, y: y)
}
func center(from: CGRect, to: CGRect) -> CGPoint {
let x = self.x + (to.size.width - from.size.width) / 2 - from.origin.x + to.origin.x
let y = self.y + (to.size.height - from.size.height) / 2 - from.origin.y + to.origin.y
return .init(x: x, y: y)
}
}
enum ArrowContentMode {
case center
case reScale
}
extension ArrowContentMode {
func transform(point: CGPoint, from: CGRect, to: CGRect) -> CGPoint {
switch self {
case .center:
return point.center(from: from, to: to)
case .reScale:
return point.reScale(from: from, to: to)
}
}
}
struct ArrowShape : Shape {
let contentMode : ArrowContentMode = .center
func path(in rect: CGRect) -> Path {
var path = Path()
let points = [
CGPoint(x: 0, y: 8),
CGPoint(x: 0, y: -8),
CGPoint(x: 0, y: 8),
CGPoint(x: 5.66, y: 2.34),
CGPoint(x: 0, y: 8),
CGPoint(x: -5.66, y: 2.34)
]
let minX = points.min { $0.x < $1.x }?.x ?? 0
let minY = points.min { $0.y < $1.y }?.y ?? 0
let maxX = points.max { $0.x < $1.x }?.x ?? 0
let maxY = points.max { $0.y < $1.y }?.y ?? 0
let fromRect = CGRect.init(x: minX, y: minY, width: maxX-minX, height: maxY-minY)
print("fromRect nx: ",minX,minY,maxX,maxY)
print("fromRect: \(fromRect), toRect: \(rect)")
let transformed = points.map { contentMode.transform(point: $0, from: fromRect, to: rect) }
print("fromRect: transformed=>\(transformed)")
path.move(to: transformed[0])
path.addLine(to: transformed[1])
path.move(to: transformed[2])
path.addLine(to: transformed[3])
path.move(to: transformed[4])
path.addLine(to: transformed[5])
return path
}
}
what I need is a way to tell when the user releases the scrollview, and if the pull to refresh arrow passed the threshold and was rotated, the scroll will move to a certain offset (say 32), and hide the arrow and show an ActivityIndicator.
NOTE: I tried using DragGesture but:
* it wont work on the scroll view
* OR block the scrolling on the scrollview content
You can use Introspect to get the UIScrollView, then from that get the publisher for UIScrollView.contentOffset and UIScrollView.isDragging to get updates on those values which you can use to manipulate your SwiftUI views.
struct Example: View {
#State var isDraggingPublisher = Just(false).eraseToAnyPublisher()
#State var offsetPublisher = Just(.zero).eraseToAnyPublisher()
var body: some View {
...
.introspectScrollView {
self.isDraggingPublisher = $0.publisher(for: \.isDragging).eraseToAnyPublisher()
self.offsetPublisher = $0.publisher(for: \.contentOffset).eraseToAnyPublisher()
}
.onReceive(isDraggingPublisher) {
// do something with isDragging change
}
.onReceive(offsetPublisher) {
// do something with offset change
}
...
}
If you want to look at an example; I use this method to get the offset publisher in my package ScrollViewProxy.