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

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:

Related

Slide Carousel cards on Cards tap in SwiftUI

I have created a carousel cards in SwiftUI, it is working on the DragGesture
I want to achieve same experience on the tap of cards i.e. on .onTapGesture, which ever cards is being tapped it should be slide to centre on the screen like shown in the video attached
My current code -
import SwiftUI
struct Item: Identifiable {
var id: Int
var title: String
var color: Color
}
class Store: ObservableObject {
#Published var items: [Item]
let colors: [Color] = [.red, .orange, .blue, .teal, .mint, .green, .gray, .indigo, .black]
// dummy data
init() {
items = []
for i in 0...7 {
let new = Item(id: i, title: "Item \(i)", color: colors[i])
items.append(new)
}
}
}
struct ContentView: View {
#StateObject var store = Store()
#State private var snappedItem = 0.0
#State private var draggingItem = 0.0
#State var activeIndex: Int = 0
var body: some View {
ZStack {
ForEach(store.items) { item in
// article view
ZStack {
RoundedRectangle(cornerRadius: 18)
.fill(item.color)
Text(item.title)
.padding()
}
.frame(width: 200, height: 200)
.scaleEffect(1.0 - abs(distance(item.id)) * 0.2 )
.opacity(1.0 - abs(distance(item.id)) * 0.3 )
.offset(x: myXOffset(item.id), y: 0)
.zIndex(1.0 - abs(distance(item.id)) * 0.1)
}
}
.gesture(getDragGesture())
.onTapGesture {
//move card to centre
}
}
private func getDragGesture() -> some Gesture {
DragGesture()
.onChanged { value in
draggingItem = snappedItem + value.translation.width / 100
}
.onEnded { value in
withAnimation {
draggingItem = snappedItem + value.predictedEndTranslation.width / 100
draggingItem = round(draggingItem).remainder(dividingBy: Double(store.items.count))
snappedItem = draggingItem
//Get the active Item index
self.activeIndex = store.items.count + Int(draggingItem)
if self.activeIndex > store.items.count || Int(draggingItem) >= 0 {
self.activeIndex = Int(draggingItem)
}
}
}
}
func distance(_ item: Int) -> Double {
return (draggingItem - Double(item)).remainder(dividingBy: Double(store.items.count))
}
func myXOffset(_ item: Int) -> Double {
let angle = Double.pi * 2 / Double(store.items.count) * distance(item)
return sin(angle) * 200
}
}
You need to apply the .onTapGesture() modifier to the single item inside the ForEach, not around it.
Then, you just need to handle the different cases, comparing the tapped item with the one currently on the front, and change the value of draggingItem accordingly.
Here's the code inside the view's body:
ZStack {
ForEach(store.items) { item in
// article view
ZStack {
RoundedRectangle(cornerRadius: 18)
.fill(item.color)
Text(item.title)
.padding()
}
.frame(width: 200, height: 200)
.scaleEffect(1.0 - abs(distance(item.id)) * 0.2 )
.opacity(1.0 - abs(distance(item.id)) * 0.3 )
.offset(x: myXOffset(item.id), y: 0)
.zIndex(1.0 - abs(distance(item.id)) * 0.1)
// Here is the modifier - on the item, not on the ForEach
.onTapGesture {
// withAnimation is necessary
withAnimation {
draggingItem = Double(item.id)
}
}
}
}
.gesture(getDragGesture())

Looping a sequence of withAnimation functions

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

Swing passing a #State var as argument does not update view

I am following the Apple Developer Tutorial for SwingUI and I am pretty new. I am working on animations currently and I am confused about #State variables. Let's look at the following code:
import SwiftUI
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 {
VStack {
HikeGraph(hike: hike, path: self.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.green
: Color.accentColor)
.animation(nil)
}
}
}
}
}
}
HikeGraph is another SwiftUI View that produces a graph, it has three options:
- elevation => gray color
- heartRate => red color
- pace => purple color
Each of them has a different color and shape, however when I change between these three options, I only see one type of graph which is the initially defined one.
#State var dataToShow = \Hike.Observation.elevation
When I change the path variable of:
HikeGraph(hike: hike, path: dataToShow)
.frame(height: 200)
The graph does not change. However, when I change dataToShow variable by myself, I can observe the color change.
My question is how can I update the graph itself when the state variable is changed.
Edit: HikeGraph View
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)
}
}
}
}

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.