Looping a sequence of withAnimation functions - swift

I'm creating a loading animation in SwiftUI (since Lottie is not supported for WatchOS and I can't get SDWebImageLottieCoder to work together with SwiftUI). This animation consists of a function called animateSpinner which goes over a bunch of withAnimation functions together with Dispatchqueue to mimic keyframes (delay was not working either).
Now this allows me to play the animation once, but how can I change the function to play forever ?
I tried looking into putting it inside a timer, or to use "while true" but all were unsuccessful.
Any ideas ?
struct MyView: View {
#State private var intro = false
#State private var mainIn = false
#State private var mainOut = false
#State private var outro = false
#State private var hideIntro: Bool = false
#State private var hideMainIn = true
#State private var hideMainOut = true
#State private var hideOutro = true
var body: some View {
ZStack {
// 01. Light Circle from background to right side
CirclesAnimated(lightCircleScaleFrom: 0.1, lightCircleScaleTo: 0.125, darkCircleScaleFrom: 0.15, darkCircleScaleTo: 0.125, lightCirclePositionFrom: 0, lightCirclePositionTo: 25, darkCirclePositionFrom: 0, darkCirclePositionTo: -25, lightCircleDarknessFrom: -0.5, lightCircleDarknessTo: -0.25,darkCircleDarknessFrom: 0, darkCircleDarknessTo: -0.25, frontCircle: .dark, animate: $intro)
.opacity(hideIntro ? 0 : 1)
// 02. Light Circle from right side to foreground.
CirclesAnimated(lightCircleScaleFrom: 0.125, lightCircleScaleTo: 0.15, darkCircleScaleFrom: 0.125, darkCircleScaleTo: 0.1, lightCirclePositionFrom: 25, lightCirclePositionTo: 0, darkCirclePositionFrom: -25, darkCirclePositionTo: 0, lightCircleDarknessFrom: -0.25, lightCircleDarknessTo: 0, darkCircleDarknessFrom: -0.25, darkCircleDarknessTo: -0.5, frontCircle: .light, animate: $mainIn)
.opacity(hideMainIn ? 0 : 1)
// 03. Light Circle from foreground to left side
CirclesAnimated(lightCircleScaleFrom: 0.15, lightCircleScaleTo: 0.125, darkCircleScaleFrom: 0.1, darkCircleScaleTo: 0.125, lightCirclePositionFrom: 0, lightCirclePositionTo: -25, darkCirclePositionFrom: 0, darkCirclePositionTo: 25, lightCircleDarknessFrom: 0, lightCircleDarknessTo: -0.25, darkCircleDarknessFrom: -0.5, darkCircleDarknessTo: -0.25, frontCircle: .light, animate: $mainOut)
.opacity(hideMainOut ? 0 : 1)
// 04. Light Circle from left side to background
CirclesAnimated(lightCircleScaleFrom: 0.125, lightCircleScaleTo: 0.1, darkCircleScaleFrom: 0.125, darkCircleScaleTo: 0.15, lightCirclePositionFrom: -25, lightCirclePositionTo: 0, darkCirclePositionFrom: 25, darkCirclePositionTo: 0, lightCircleDarknessFrom: -0.25, lightCircleDarknessTo: -0.5, darkCircleDarknessFrom: -0.25, darkCircleDarknessTo: 0, frontCircle: .dark, animate: $outro)
.opacity(hideOutro ? 0 : 1)
}
.onAppear {
animateSpinner()
}
}
private func animateSpinner() {
withAnimation(Animation.easeInOut(duration: 1)) {
intro.toggle()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.hideIntro = true
self.hideMainIn = false
withAnimation(Animation.easeInOut(duration: 1)) {
mainIn.toggle()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.hideMainIn = true
self.hideMainOut = false
withAnimation(Animation.easeInOut(duration: 1)) {
mainOut.toggle()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.hideMainOut = true
self.hideOutro = false
withAnimation(Animation.easeInOut(duration: 1)) {
outro.toggle()
}
}
}
}
enum FrontCircle {
case light
case dark
}
struct CirclesAnimated: View {
var lightCircleScaleFrom: CGFloat
var lightCircleScaleTo: CGFloat
var darkCircleScaleFrom: CGFloat
var darkCircleScaleTo: CGFloat
var lightCirclePositionFrom: CGFloat
var lightCirclePositionTo: CGFloat
var darkCirclePositionFrom: CGFloat
var darkCirclePositionTo: CGFloat
var lightCircleDarknessFrom: Double
var lightCircleDarknessTo: Double
var darkCircleDarknessFrom: Double
var darkCircleDarknessTo: Double
var frontCircle: FrontCircle
#EnvironmentObject var theme: ThemeSelectionService
#Binding var animate: Bool
var body: some View {
if frontCircle == .dark {
ZStack {
Circle()
.scale(animate ? lightCircleScaleTo : lightCircleScaleFrom)
.foregroundColor(theme.lightColor)
.offset(x: animate ? lightCirclePositionTo : lightCirclePositionFrom)
.brightness(animate ? lightCircleDarknessTo : lightCircleDarknessFrom)
Circle()
.scale(animate ? darkCircleScaleTo : darkCircleScaleFrom)
.foregroundColor(theme.darkColor)
.offset(x: animate ? darkCirclePositionTo : darkCirclePositionFrom)
.brightness(animate ? darkCircleDarknessTo : darkCircleDarknessFrom)
}
} else {
ZStack {
Circle()
.scale(animate ? darkCircleScaleTo : darkCircleScaleFrom)
.foregroundColor(theme.darkColor)
.offset(x: animate ? darkCirclePositionTo : darkCirclePositionFrom)
.brightness(animate ? darkCircleDarknessTo : darkCircleDarknessFrom)
Circle()
.scale(animate ? lightCircleScaleTo : lightCircleScaleFrom)
.foregroundColor(theme.lightColor)
.offset(x: animate ? lightCirclePositionTo : lightCirclePositionFrom)
.brightness(animate ? lightCircleDarknessTo : lightCircleDarknessFrom)
}
}
}
}

Here's an alternative way to do the animation. I try to avoid DispatchQueues in the middle of animations because they can be glitchy at times. I used a Timer to update a counter with circlePositions 0,1,2,3 and updated modifiers based on the circle's position. I incorporated an offset for the 2nd circle so that it's always 2 positions ahead of the first one.
struct MyView: View {
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
#State var counter: Int = 0
var body: some View {
ZStack {
Circle()
.scaleEffect(getScale(n: 0))
.offset(x: getOffset(n: 0))
.brightness(getBrightness(n: 0))
.zIndex(getZIndex(n: 0))
Circle()
.scaleEffect(getScale(n: 2))
.offset(x: getOffset(n: 2))
.brightness(getBrightness(n: 2))
.zIndex(getZIndex(n: 2))
}
.foregroundColor(.blue)
.onReceive(timer, perform: { _ in
withAnimation(Animation.easeInOut(duration: 1.0)) {
counter = getPosition(n: 1)
}
})
// TO BE DELETED
.overlay(
Text("\(counter) : \(getPosition(n: 2))")
.foregroundColor(.red)
.animation(nil)
, alignment: .top
)
}
func getPosition(n circleOffset: Int) -> Int {
return (counter + circleOffset) % 4
}
func getScale(n circleOffset: Int) -> CGFloat {
switch getPosition(n: circleOffset) {
case 1: return 0.075
case 3: return 0.125
default: return 0.1
}
}
func getOffset(n circleOffset: Int) -> CGFloat {
switch getPosition(n: circleOffset) {
case 0: return -25
case 2: return 25
default: return 0
}
}
func getBrightness(n circleOffset: Int) -> Double {
switch getPosition(n: circleOffset) {
case 1: return -0.5
case 3: return 0
default: return -0.25
}
}
func getZIndex(n circleOffset: Int) -> Double {
switch getPosition(n: circleOffset) {
case 0, 3: return 2
default: return 1
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
MyView()
}
}

Related

How to show an overlay on swipe/drag gesture

I want to show an overlay that displays a different image depending on which side the card is dragged towards. I tried setting a default image but that way the image never updated, so I am trying with a string of the imageName instead. I implemented a tinder like swiping already:
class CardViewModel : ObservableObject {
#Published var offset = CGSize.zero
}
struct CardView: View {
#State var offset = CGSize.zero
#StateObject var cardVM = CardViewModel()
#State var imageName = ""
#State var isDragging = false
func swipeCard(width: CGFloat) {
switch width {
case -500...(-150):
offset = CGSize(width: -500, height: 0)
self.imageName = "nope"
case 150...500:
offset = CGSize(width: 500, height: 0)
self.imageName = "bid"
default:
offset = .zero
}
}
var body: some View {
VStack {
}.overlay(
isDragging ? Image(imageName) : Image("")
)
.offset(x: offset.width, y: offset.height * 0.4)
.rotationEffect(.degrees(Double(offset.width / 40)))
.animation(.spring())
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
isDragging = true
} .onEnded { _ in
withAnimation {
swipeCard(width: offset.width)
isDragging = false
}
}
).offset(x: cardVM.offset.width, y: cardVM.offset.height * 0.4)
.rotationEffect(.degrees(Double(cardVM.offset.width / 40)))
.animation(.spring())
}
}

Is there a way to add a flip animation when I tap on my text?

Im trying to make a flash cards program and I can't seem to figure out how to make a flip animation to reveal the corresponding answer. Here is my code:
Text( "\(text)" + " \(X)" + " \(text1)")
.frame(width: 120, height: 300, alignment: .center)
.onTapGesture {
if selection == "Multiply" { text = num1 * num2 clear() }
if selection == "Addition" { text = num1 + num2 clear() }
if selection == "Subtraction" { text = num1 - num2 clear() }
if selection == "Division" { divAnswer = Double(num1 / num2) text = num1 / num2 clear() }
}
(im very new to swifui)
Based on the tutorial from the link in the comment, I set up the following. It created just one card with one exercise, but it shows how it could work. You would have to adjust it to your needs:
//
// CardFlip.swift
// CardFlip
//
// Created by Sebastian Fox on 26.08.22.
//
import SwiftUI
struct ContentView: View {
// For testing, you can setup your exercise here
#State var task: Task = Task(operator1: 5, operator2: 10, operation: .addition)
//MARK: Variables
#State var backDegree = 0.0
#State var frontDegree = -90.0
#State var isFlipped = false
let width : CGFloat = 200
let height : CGFloat = 250
let durationAndDelay : CGFloat = 0.3
//MARK: Flip Card Function
func flipCard () {
isFlipped = !isFlipped
if isFlipped {
withAnimation(.linear(duration: durationAndDelay)) {
backDegree = 90
}
withAnimation(.linear(duration: durationAndDelay).delay(durationAndDelay)){
frontDegree = 0
}
} else {
withAnimation(.linear(duration: durationAndDelay)) {
frontDegree = -90
}
withAnimation(.linear(duration: durationAndDelay).delay(durationAndDelay)){
backDegree = 0
}
}
}
//MARK: View Body
var body: some View {
ZStack {
CardBack(width: width, height: height, degree: $backDegree, task: $task)
CardFront(width: width, height: height, degree: $frontDegree, task: $task)
}.onTapGesture {
flipCard ()
}
}
}
struct CardFront : View {
let width : CGFloat
let height : CGFloat
#Binding var degree : Double
#Binding var task: Task
func calcResult() -> Double {
var result: Double!
switch task.operation {
case .addition: result = task.operator1 + task.operator2
case .substraction: result = task.operator1 - task.operator2
case .mulitply: result = task.operator1 * task.operator2
case .division: result = task.operator1 / task.operator2
}
return result
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(.white)
.frame(width: width, height: height)
.shadow(color: .gray, radius: 2, x: 0, y: 0)
.overlay(
Text("\(calcResult(), specifier: "%.2f")"))
}.rotation3DEffect(Angle(degrees: degree), axis: (x: 0, y: 1, z: 0))
}
}
struct CardBack : View {
let width : CGFloat
let height : CGFloat
#Binding var degree : Double
#Binding var task: Task
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(.white)
.frame(width: width, height: height)
.shadow(color: .gray, radius: 2, x: 0, y: 0)
.overlay(
Text("\(String(task.operator1)) \(task.operation.rawValue) \(String(task.operator2))")
)
}.rotation3DEffect(Angle(degrees: degree), axis: (x: 0, y: 1, z: 0))
}
}
struct Task {
var operator1: Double
var operator2: Double
var operation: Operations
}
enum Operations: String, CaseIterable {
case addition = "+"
case division = "/"
case mulitply = "x"
case substraction = "-"
}
Here a gif of how it looks like in the simulator:

How to make my timer more precise in SwiftUI?

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")
}
}
}

How to rearrange views in SwiftUI ZStack by dragging

How can I rearrange a view's position in a ZStack by dragging it above or below another view (e.g. in this instance how can I rearrange the order of the cards in the deck by dragging a card above or below another card, to move the dragged card behind or in front of said card in deck).
I want for the card to change indices when dragged up or down in the stack and fluidly appear behind each and every card in the stack as it is dragged- and stay there on mouse up.
Summary: In other words, the card dragged and the cards above it should switch as I drag up and the card dragged and the cards below it should switch as I drag down.
I figure this has something to do with changing the ZStack order in struct CardView: View and updating the position from inside DragGesture().onChanged by evaluating how much the card has been dragged (perhaps by viewing the self.offset value) but I have not been able to work out how to do this in a reliable way.
Here's what I have right now:
Code:
import SwiftUI
let cardSpace:CGFloat = 10
struct ContentView: View {
#State var cardColors: [Color] = [.orange, .green, .yellow, .purple, .red, .orange, .green, .yellow, .purple]
var body: some View {
HStack {
VStack {
CardView(colors: self.$cardColors)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.position(x: 370, y: 300)
}
}
struct CardView: View {
#State var offset = CGSize.zero
#State var dragging:Bool = false
#State var tapped:Bool = false
#State var tappedLocation:Int = -1
#Binding var colors: [Color]
#State var locationDragged:Int = -1
var body: some View {
GeometryReader { reader in
ZStack {
ForEach(0..<self.colors.count, id: \.self) { i in
ColorCard(reader:reader, i:i, colors: self.$colors, offset: self.$offset, tappedLocation: self.$tappedLocation, locationDragged:self.$locationDragged, tapped: self.$tapped, dragging: self.$dragging)
}
}
}
.animation(.spring())
}
}
struct ColorCard: View {
var reader: GeometryProxy
var i:Int
#State var offsetHeightBeforeDragStarted: Int = 0
#Binding var colors: [Color]
#Binding var offset: CGSize
#Binding var tappedLocation:Int
#Binding var locationDragged:Int
#Binding var tapped:Bool
#Binding var dragging:Bool
var body: some View {
VStack {
Group {
VStack {
self.colors[i]
}
.frame(width: 300, height: 400)
.cornerRadius(20).shadow(radius: 20)
.offset(
x: (self.locationDragged == i) ? CGFloat(i) * self.offset.width / 14
: 0,
y: (self.locationDragged == i) ? CGFloat(i) * self.offset.height / 4
: 0
)
.offset(
x: (self.tapped && self.tappedLocation != i) ? 100 : 0,
y: (self.tapped && self.tappedLocation != i) ? 0 : 0
)
.position(x: reader.size.width / 2, y: (self.tapped && self.tappedLocation == i) ? -(cardSpace * CGFloat(i)) + 0 : reader.size.height / 2)
}
.rotationEffect(
(i % 2 == 0) ? .degrees(-0.2 * Double(arc4random_uniform(15)+1) ) : .degrees(0.2 * Double(arc4random_uniform(15)+1) )
)
.onTapGesture() { //Show the card
self.tapped.toggle()
self.tappedLocation = self.i
}
.gesture(
DragGesture()
.onChanged { gesture in
self.locationDragged = self.i
self.offset = gesture.translation
self.dragging = true
}
.onEnded { _ in
self.locationDragged = -1 //Reset
self.offset = .zero
self.dragging = false
self.tapped = false //enable drag to dismiss
self.offsetHeightBeforeDragStarted = Int(self.offset.height)
}
)
}.offset(y: (cardSpace * CGFloat(i)))
}
}
check this out:
the "trick" is that you just need to reorder the z order of the items. therefore you have to "hold" the cards in an array.
let cardSpace:CGFloat = 10
struct Card : Identifiable, Hashable, Equatable {
static func == (lhs: Card, rhs: Card) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
var id = UUID()
var intID : Int
static let cardColors: [Color] = [.orange, .green, .yellow, .purple, .red, .orange, .green, .yellow, .purple]
var zIndex : Int
var color : Color
}
class Data: ObservableObject {
#Published var cards : [Card] = []
init() {
for i in 0..<Card.cardColors.count {
cards.append(Card(intID: i, zIndex: i, color: Card.cardColors[i]))
}
}
}
struct ContentView: View {
#State var data : Data = Data()
var body: some View {
HStack {
VStack {
CardView().environmentObject(data)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// .position(x: 370, y: 300)
}
}
struct CardView: View {
#EnvironmentObject var data : Data
#State var offset = CGSize.zero
#State var dragging:Bool = false
#State var tapped:Bool = false
#State var tappedLocation:Int = -1
#State var locationDragged:Int = -1
var body: some View {
GeometryReader { reader in
ZStack {
ForEach(self.data.cards, id: \.self) { card in
ColorCard(card: card, reader:reader, offset: self.$offset, tappedLocation: self.$tappedLocation, locationDragged:self.$locationDragged, tapped: self.$tapped, dragging: self.$dragging)
.environmentObject(self.data)
.zIndex(Double(card.zIndex))
}
}
}
.animation(.spring())
}
}
struct ColorCard: View {
#EnvironmentObject var data : Data
var card: Card
var reader: GeometryProxy
#State var offsetHeightBeforeDragStarted: Int = 0
#Binding var offset: CGSize
#Binding var tappedLocation:Int
#Binding var locationDragged:Int
#Binding var tapped:Bool
#Binding var dragging:Bool
var body: some View {
VStack {
Group {
VStack {
card.color
}
.frame(width: 300, height: 400)
.cornerRadius(20).shadow(radius: 20)
.offset(
x: (self.locationDragged == card.intID) ? CGFloat(card.zIndex) * self.offset.width / 14
: 0,
y: (self.locationDragged == card.intID) ? CGFloat(card.zIndex) * self.offset.height / 4
: 0
)
.offset(
x: (self.tapped && self.tappedLocation != card.intID) ? 100 : 0,
y: (self.tapped && self.tappedLocation != card.intID) ? 0 : 0
)
.position(x: reader.size.width / 2, y: (self.tapped && self.tappedLocation == card.intID) ? -(cardSpace * CGFloat(card.zIndex)) + 0 : reader.size.height / 2)
}
.rotationEffect(
(card.zIndex % 2 == 0) ? .degrees(-0.2 * Double(arc4random_uniform(15)+1) ) : .degrees(0.2 * Double(arc4random_uniform(15)+1) )
)
.onTapGesture() { //Show the card
self.tapped.toggle()
self.tappedLocation = self.card.intID
}
.gesture(
DragGesture()
.onChanged { gesture in
self.locationDragged = self.card.intID
self.offset = gesture.translation
if self.offset.height > 60 ||
self.offset.height < -60 {
withAnimation {
if let index = self.data.cards.firstIndex(of: self.card) {
self.data.cards.remove(at: index)
self.data.cards.append(self.card)
for index in 0..<self.data.cards.count {
self.data.cards[index].zIndex = index
}
}
}
}
self.dragging = true
}
.onEnded { _ in
self.locationDragged = -1 //Reset
self.offset = .zero
self.dragging = false
self.tapped = false //enable drag to dismiss
self.offsetHeightBeforeDragStarted = Int(self.offset.height)
}
)
}.offset(y: (cardSpace * CGFloat(card.zIndex)))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(Data())
}
}
Just an idea (cause requires re-think/re-code your solution). Reorder in your case needs usage/modification of card zIndex so it needs to be stored somewhere.
Thus instead of directly use color as model you needed more explicit model object
struct Card {
var color: Color
var deckOrder: Int
}
Note: below is in pseudo-code, you have to adapt it yourself
next, you keep and iterate by cards (and I would separate them info ObsesrvableObject view model)
ForEach(Array(vm.cards.enumerated()), id: \.element) { i, card in
ColorCard(reader:reader, i:i, cards: self.$vm.cards,
offset: self.$offset, tappedLocation: self.$tappedLocation,
locationDragged:self.$locationDragged, tapped: self.$tapped,
dragging: self.$dragging)
.zIndex(card.deckOrder)
}
now changing card.deckOrder on drag you will change zIndex of view/card in deck.
Building on Chris's answer I have come up with this.
Some shortcomings are: it requires one drag per movement versus one drag to move up or down the deck indefinitely.
Demo:
import SwiftUI
let cardSpace:CGFloat = 10 + 20
struct Card : Identifiable, Hashable, Equatable {
static func == (lhs: Card, rhs: Card) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
var id = UUID()
var intID : Int
static let cardColors: [Color] = [.orange, .green, .yellow, .purple, .red, .orange, .green, .yellow, .purple]
var zIndex : Int
var color : Color
}
class Data: ObservableObject {
#Published var cards : [Card] = []
init() {
for i in 0..<Card.cardColors.count {
cards.append(Card(intID: i, zIndex: i, color: Card.cardColors[i]))
}
}
}
struct ContentView: View {
#State var data : Data = Data()
var body: some View {
HStack {
VStack {
CardView().environmentObject(data)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// .position(x: 370, y: 300)
}
}
struct CardView: View {
#EnvironmentObject var data : Data
#State var offset = CGSize.zero
#State var dragging:Bool = false
#State var tapped:Bool = false
#State var tappedLocation:Int = -1
#State var locationDragged:Int = -1
var body: some View {
GeometryReader { reader in
ZStack {
ForEach((0..<self.data.cards.count), id: \.self) { i in
ColorCard(card: self.data.cards[i], reader:reader, offset: self.$offset, tappedLocation: self.$tappedLocation, locationDragged:self.$locationDragged, tapped: self.$tapped, dragging: self.$dragging, i: i)
.environmentObject(self.data)
.zIndex(Double(self.data.cards[i].zIndex))
}
}
}
.animation(.spring())
}
}
struct ColorCard: View {
#EnvironmentObject var data : Data
var card: Card
var reader: GeometryProxy
#State var offsetHeightBeforeDragStarted: Int = 0
#Binding var offset: CGSize
#Binding var tappedLocation:Int
#Binding var locationDragged:Int
#Binding var tapped:Bool
#Binding var dragging:Bool
#State var i: Int
#State var numTimesCalledSinceDragBegan: Int = 0
var body: some View {
VStack {
Group {
VStack {
card.color
}
.frame(width: 300, height: 400)
.cornerRadius(20).shadow(radius: 20)
.offset(
x: (self.numTimesCalledSinceDragBegan <= 1 && self.locationDragged == card.intID) ? CGFloat(card.zIndex) * self.offset.width / 14
: 0,
y: (self.numTimesCalledSinceDragBegan <= 1 && self.locationDragged == card.intID) ? CGFloat(card.zIndex) * self.offset.height / 4
: 0
)
.offset(
x: (self.tapped && self.tappedLocation != card.intID) ? 100 : 0,
y: (self.tapped && self.tappedLocation != card.intID) ? 0 : 0
)
.position(x: reader.size.width / 2, y: (self.tapped && self.tappedLocation == card.intID) ? -(cardSpace * CGFloat(card.zIndex)) + 0 : reader.size.height / 2)
}
// .rotationEffect(
// (card.zIndex % 2 == 0) ? .degrees(-0.2 * Double(arc4random_uniform(15)+1) ) : .degrees(0.2 * Double(arc4random_uniform(15)+1) )
// )
// .onTapGesture() { //Show the card
// self.tapped.toggle()
// self.tappedLocation = self.card.intID
//
// }
.gesture(
DragGesture()
.onChanged { gesture in
self.numTimesCalledSinceDragBegan += 1
self.locationDragged = self.card.intID
self.offset = gesture.translation
if(self.numTimesCalledSinceDragBegan == 1) {
if let index = self.data.cards.firstIndex(of: self.card) {
if(self.offset.height >= 0) {self.i += 1 } else {self.i -= 1}
self.data.cards.remove(at: index)
self.data.cards.insert(self.card, at:
(self.offset.height >= 0) ? self.i : self.i
)
for index in 0..<self.data.cards.count {
self.data.cards[index].zIndex = index
}
}
}
self.dragging = true
}
.onEnded { _ in
self.locationDragged = -1 //Reset
self.offset = .zero
self.dragging = false
self.tapped = false //enable drag to dismiss
self.offsetHeightBeforeDragStarted = Int(self.offset.height)
self.numTimesCalledSinceDragBegan = 0
}
)
}.offset(y: (cardSpace * CGFloat(card.zIndex)))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(Data())
}
}

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.