How to make zoom in zoom out button animation on tap gesture in SwiftUI? - ios

Simple and regular approach to animate a bump effect for a button but not simple in SwiftUI.
I'm trying to change scale in tapGesture modifier, but it doesn't have any effect. I don't know how to make chain of animations, probably because SwiftUI doesn't have it. So my naive approach was:
#State private var scaleValue = CGFloat(1)
...
Button(action: {
withAnimation {
self.scaleValue = 1.5
}
withAnimation {
self.scaleValue = 1.0
}
}) {
Image("button1")
.scaleEffect(self.scaleValue)
}
Obviously it doesn't work and buttons image get last scale value immediately.
My second thought was to change scale to 0.8 value on hold event and then after release event make scale to 1.2 and again after few mseconds change it to 1.0. I guess this algorithm should make nice and more natural bump effect. But I couldn't find suitable gesture struct in SwiftUI to handle hold-n-release event.
P.S. For ease understanding, I will describe the steps of the hold-n-release algorithm:
Scale value is 1.0
User touch the button
The button scale becomes 0.8
User release the button
The button scale becomes 1.2
Delay 0.1 sec
The button scale go back to default 1.0
UPD: I found a simple solution using animation delay modifier. But I'm not sure it's right and clear. Also it doens't cover hold-n-release issue:
#State private var scaleValue = CGFloat(1)
...
Button(action: {
withAnimation {
self.scaleValue = 1.5
}
//
// Using delay for second animation block
//
withAnimation(Animation.linear.delay(0.2)) {
self.scaleValue = 1.0
}
}) {
Image("button1")
.scaleEffect(self.scaleValue)
}
UPD 2:
I noticed in solution above it doesn't matter what value I pass as argument to delay modifier: 0.2 or 1000 will have same effect. Perhaps it's a bug 🐞
So I've used Timer instance instead of delay animation modifier. And now it's working as expected:
...
Button(action: {
withAnimation {
self.scaleValue = 1.5
}
//
// Replace it
//
// withAnimation(Animation.linear.delay(0.2)) {
// self.scaleValue = 1.0
// }
//
// by Timer with 0.5 msec delay
//
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
withAnimation {
self.scaleValue = 1.0
}
}
}) {
...
UPD 3:
Until we waiting official Apple update, one of suitable solution for realization of two events touchStart and touchEnd is based on #average Joe answer:
import SwiftUI
struct TouchGestureViewModifier: ViewModifier {
let minimumDistance: CGFloat
let touchBegan: () -> Void
let touchEnd: (Bool) -> Void
#State private var hasBegun = false
#State private var hasEnded = false
init(minimumDistance: CGFloat, touchBegan: #escaping () -> Void, touchEnd: #escaping (Bool) -> Void) {
self.minimumDistance = minimumDistance
self.touchBegan = touchBegan
self.touchEnd = touchEnd
}
private func isTooFar(_ translation: CGSize) -> Bool {
let distance = sqrt(pow(translation.width, 2) + pow(translation.height, 2))
return distance >= minimumDistance
}
func body(content: Content) -> some View {
content.gesture(DragGesture(minimumDistance: 0)
.onChanged { event in
guard !self.hasEnded else { return }
if self.hasBegun == false {
self.hasBegun = true
self.touchBegan()
} else if self.isTooFar(event.translation) {
self.hasEnded = true
self.touchEnd(false)
}
}
.onEnded { event in
if !self.hasEnded {
let success = !self.isTooFar(event.translation)
self.touchEnd(success)
}
self.hasBegun = false
self.hasEnded = false
}
)
}
}
extension View {
func onTouchGesture(minimumDistance: CGFloat = 20.0,
touchBegan: #escaping () -> Void,
touchEnd: #escaping (Bool) -> Void) -> some View {
modifier(TouchGestureViewModifier(minimumDistance: minimumDistance, touchBegan: touchBegan, touchEnd: touchEnd))
}
}

struct ScaleButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 2 : 1)
}
}
struct Test2View: View {
var body: some View {
Button(action: {}) {
Image("button1")
}.buttonStyle(ScaleButtonStyle())
}
}

Upgrading code for answer for iOS 15 (but this also available in iOS 13).
The one-parameter form of the animation() modifier has now been formally deprecated, mostly because it caused all sorts of unexpected behaviors (for ex in Lazy stacks: lazy(v/h)scroll, lazyvgrid): Button animated unexpectedly (jumping) during scrolling.
public struct ScaleButtonStyle: ButtonStyle {
public init() {}
public func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.95 : 1)
.animation(.linear(duration: 0.2), value: configuration.isPressed)
.brightness(configuration.isPressed ? -0.05 : 0)
}
}
public extension ButtonStyle where Self == ScaleButtonStyle {
static var scale: ScaleButtonStyle {
ScaleButtonStyle()
}
}

Yes, it looks like a bug but after my experimenting I found that you can do so
I've post a demo at https://youtu.be/kw4EIOCp78g
struct TestView: View {
#State private var scaleValue = CGFloat(1)
var body: some View {
ZStack {
CustomButton(
touchBegan: {
withAnimation {
self.scaleValue = 2
}
},
touchEnd: {
withAnimation {
self.scaleValue = 1
}
}
){
Image("button1")
}.frame(width: 100, height: 100)
Image("button1").opacity(scaleValue > 1 ? 1 : 0).scaleEffect(self.scaleValue)
}
}
}
struct CustomButton<Content: View>: UIViewControllerRepresentable {
var content: Content
var touchBegan: () -> ()
var touchEnd: () -> ()
typealias UIViewControllerType = CustomButtonController<Content>
init(touchBegan: #escaping () -> (), touchEnd: #escaping () -> (), #ViewBuilder content: #escaping () -> Content) {
self.touchBegan = touchBegan
self.touchEnd = touchEnd
self.content = content()
}
func makeUIViewController(context: Context) -> UIViewControllerType {
CustomButtonController(rootView: self.content, touchBegan: touchBegan, touchEnd: touchEnd)
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
}
}
class CustomButtonController<Content: View>: UIHostingController<Content> {
var touchBegan: () -> ()
var touchEnd: () -> ()
init(rootView: Content, touchBegan: #escaping () -> (), touchEnd: #escaping () -> ()) {
self.touchBegan = touchBegan
self.touchEnd = touchEnd
super.init(rootView: rootView)
self.view.isMultipleTouchEnabled = true
}
#objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
self.touchBegan()
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
self.touchEnd()
}
//touchesEnded only works if the user moves his finger beyond the bound of the image and releases
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
self.touchEnd()
}
}
There is another strange thing if we move and scale the second Image to the first then it will not be shown without .frame(width: 100, height: 100).

Nice and clean swiftUI solution:
#State private var scaleValue = CGFloat(1)
...
Image("button1")
.scaleEffect(self.scaleValue)
.onTouchGesture(
touchBegan: { withAnimation { self.scaleValue = 1.5 } },
touchEnd: { _ in withAnimation { self.scaleValue = 1.0 } }
)
however, you also need to add this code snippet to the project:
struct TouchGestureViewModifier: ViewModifier {
let touchBegan: () -> Void
let touchEnd: (Bool) -> Void
#State private var hasBegun = false
#State private var hasEnded = false
private func isTooFar(_ translation: CGSize) -> Bool {
let distance = sqrt(pow(translation.width, 2) + pow(translation.height, 2))
return distance >= 20.0
}
func body(content: Content) -> some View {
content.gesture(DragGesture(minimumDistance: 0)
.onChanged { event in
guard !self.hasEnded else { return }
if self.hasBegun == false {
self.hasBegun = true
self.touchBegan()
} else if self.isTooFar(event.translation) {
self.hasEnded = true
self.touchEnd(false)
}
}
.onEnded { event in
if !self.hasEnded {
let success = !self.isTooFar(event.translation)
self.touchEnd(success)
}
self.hasBegun = false
self.hasEnded = false
})
}
}
extension View {
func onTouchGesture(touchBegan: #escaping () -> Void,
touchEnd: #escaping (Bool) -> Void) -> some View {
modifier(TouchGestureViewModifier(touchBegan: touchBegan, touchEnd: touchEnd))
}
}

Ok, I think I might have a decent solution here. GIST here
I've put together a bunch of things to make this work. First, a AnimatableModifier that observes if an animation has ended. All thanks to avanderlee
/// An animatable modifier that is used for observing animations for a given animatable value.
public struct AnimationCompletionObserverModifier<Value>: AnimatableModifier where Value: VectorArithmetic {
/// While animating, SwiftUI changes the old input value to the new target value using this property. This value is set to the old value until the animation completes.
public var animatableData: Value {
didSet {
notifyCompletionIfFinished()
}
}
/// The target value for which we're observing. This value is directly set once the animation starts. During animation, `animatableData` will hold the oldValue and is only updated to the target value once the animation completes.
private var targetValue: Value
/// The completion callback which is called once the animation completes.
private var completion: () -> Void
init(observedValue: Value, completion: #escaping () -> Void) {
self.completion = completion
self.animatableData = observedValue
targetValue = observedValue
}
/// Verifies whether the current animation is finished and calls the completion callback if true.
private func notifyCompletionIfFinished() {
guard animatableData == targetValue else { return }
/// Dispatching is needed to take the next runloop for the completion callback.
/// This prevents errors like "Modifying state during view update, this will cause undefined behavior."
DispatchQueue.main.async {
self.completion()
}
}
public func body(content: Content) -> some View {
/// We're not really modifying the view so we can directly return the original input value.
return content
}
}
public extension View {
/// Calls the completion handler whenever an animation on the given value completes.
/// - Parameters:
/// - value: The value to observe for animations.
/// - completion: The completion callback to call once the animation completes.
/// - Returns: A modified `View` instance with the observer attached.
func onAnimationCompleted<Value: VectorArithmetic>(for value: Value, completion: #escaping () -> Void) -> ModifiedContent<Self, AnimationCompletionObserverModifier<Value>> {
return modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion))
}
}
Now that we can track the end of a animation, another view modifer takes care of tracking the end of the zoom out animation and starts a new zoom in animation using a bunch of booleans to track the animation state. Forgive the naming.
struct ZoomInOutOnTapModifier: ViewModifier {
var destinationScaleFactor: CGFloat
var duration: TimeInterval
init(duration: TimeInterval = 0.3,
destinationScaleFactor: CGFloat = 1.2) {
self.duration = duration
self.destinationScaleFactor = destinationScaleFactor
}
#State var scale: CGFloat = 1
#State var secondHalfAnimationStarted = false
#State var animationCompleted = false
func body(content: Content) -> some View {
content
.scaleEffect(scale)
.simultaneousGesture(DragGesture(minimumDistance: 0.0, coordinateSpace: .global)
.onChanged({ _ in
animationCompleted = true
withAnimation(.linear(duration: duration)) {
scale = destinationScaleFactor
}
})
.onEnded({ _ in
withAnimation(.linear(duration: duration)) {
scale = 1
}
secondHalfAnimationStarted = true
})
)
.onAnimationCompleted(for: scale) {
if scale == 1 {
secondHalfAnimationStarted = false
animationCompleted = true } else if scale == destinationScaleFactor {
animationCompleted = false
secondHalfAnimationStarted = true
}
if !secondHalfAnimationStarted {
withAnimation(.linear(duration: duration)) {
scale = 1
}
}
}
}
}
public extension View {
func addingZoomOnTap(duration: TimeInterval = 0.3, destinationScaleFactor: CGFloat = 1.2) -> some View {
modifier(ZoomInOutOnTapModifier(duration: duration, destinationScaleFactor: destinationScaleFactor))
}
}
All put together:
PlaygroundPage.current.setLiveView(
Button {
print("Button tapped")
} label: {
Text("Tap me")
.font(.system(size: 20))
.foregroundColor(.white)
.padding()
.background(Capsule()
.fill(Color.black))
}
.addingZoomOnTap()
.frame(width: 300, height: 300)
)
Let me know if improvements can be made.
EDIT:
In case you want the button to be in the scaled state until the user lets go(touchUp) of the button, the ViewModifier become much simpler.
struct ZoomInOutOnTapModifier: ViewModifier {
var destinationScaleFactor: CGFloat
var duration: TimeInterval
init(duration: TimeInterval = 0.3,
destinationScaleFactor: CGFloat = 1.2) {
self.duration = duration
self.destinationScaleFactor = destinationScaleFactor
}
#State var scale: CGFloat = 1
func body(content: Content) -> some View {
content
.scaleEffect(scale)
.simultaneousGesture(DragGesture(minimumDistance: 0.0, coordinateSpace: .global)
.onChanged({ _ in
withAnimation(.linear(duration: duration)) {
scale = destinationScaleFactor
}
})
.onEnded({ _ in
withAnimation(.linear(duration: duration)) {
scale = 1
}
})
)
}
}

Related

How do I switch views when the timer ends SwiftUI

I have two views. The Main View and a Break View. I have a timer running in the Main View which counts down to zero. When the timer reaches zero, I want to be able to switch the screen to Break View. I am using MVVM to keep track of the timers. Using .onReceive to make it look like the timer is running in the background.
I tried using a boolean to check if the timer has reached zero and based on that changed the view, but it's not working and is giving an error saying the result of the view is not used anywhere. I have a navigation view in the Content View if that's of any help.
Thanks in advance.
A snippet of the code :
Main View :
struct MainView: View {
var body: some View {
VStack(alignment: .center, spacing: 50, content: {
Button(action: {
if !fbManager.isTimerStarted {
fbManager.start()
fbManager.isTimerStarted = true
}
else {
fbManager.pause()
fbManager.isTimerStarted = false
}
}, label: {
Image(systemName: fbManager.isTimerStarted == true ? "pause.fill" : "play.fill")
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
.foregroundColor(Color(red: 1.00, green: 1.00, blue: 1.00))
})
.onReceive(NotificationCenter.default.publisher(
for: UIScene.didEnterBackgroundNotification)) { _ in
if fbManager.isTimerStarted {
movingToBackground()
}
}
.onReceive(NotificationCenter.default.publisher(
for: UIScene.willEnterForegroundNotification)) { _ in
if fbManager.isTimerStarted {
movingToForeground()
}
}
})
}
}
func movingToBackground() {
print("Moving to the background")
notificationDate = Date()
fbManager.pause()
}
func movingToForeground() {
print("Moving to the foreground")
let deltaTime: Int = Int(Date().timeIntervalSince(notificationDate))
fbManager.secondsElapsed -= deltaTime
fbManager.start()
}
}
View Model :
class FocusBreakManager: ObservableObject {
var timer: Timer = Timer()
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [self] _ in
self.secondsElapsed -= 1
self.focusfill += 0.01667
focusTime = String(secondsElapsed)
focusTime = formatCounter()
if secondsElapsed <= 0 {
stop()
}
}
}
func formatCounter() -> String {
let minutes = Int(secondsElapsed) / 60 % 60
let seconds = Int(secondsElapsed) % 60
return String(format : "%02i : %02i", minutes, seconds)
}
}
Hey to keep up with your solution here is an example of how that could work you would need to use #ObservedObject property wrapper in order to monitor updates from your view.
struct ContentView: View {
#ObservedObject private var focusBreakManager = FocusBreakManager()
var body: some View {
VStack {
Text("\(focusBreakManager.elapsedSeconds)")
Text(focusBreakManager.timerRunningMessage)
Button("Start timer", action: focusBreakManager.start)
}
.padding()
}
}
class FocusBreakManager: ObservableObject {
var timer: Timer = Timer()
#Published var elapsedSeconds = 0
var timerRunningMessage: String {
timerRunning
? "Timer is running"
: "Timer paused"
}
private var timerRunning: Bool {
timer.isValid
}
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
self.elapsedSeconds += 1
if self.elapsedSeconds > 5 {
self.timer.invalidate()
}
}
}
}
You can also take a look at the autoconnect api here's a great tutorial:
https://www.hackingwithswift.com/books/ios-swiftui/triggering-events-repeatedly-using-a-timer

How to make the button inactive during animation

I want to deactivate the button until the animation is finished.
I want it to be active when the animation is over.
I wrote the following, but it didn't work.
It activates immediately.
import SwiftUI
struct ContentView: View {
#State private var scale: CGFloat = 1
#State private var isDisable = false
var body: some View {
VStack {
Button(
action: {
isDisable = true
withAnimation(
.linear(duration: 1)
) {
scale = scale - 0.1
isDisable = false
}
},
label: {
Text("Tap Me")
}
)
.disabled(
isDisable
)
RectangleView().scaleEffect(
scale
)
}
}
}
struct RectangleView: View {
var body: some View {
Rectangle().fill(
Color.blue
)
.frame(
width:200,
height: 150
)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
SwiftUI animations don't have completion handlers, but you can monitor the state of an animatable property and listen for changes to that. This does what you need and is not coupled to the timing of the animation
SwiftUI has AnimatableModifier which you can use to create a modifier that calls a function when the animation completes.
You can see the explanation of this at withAnimation completion callback with animatable modifiers
struct ContentView: View {
#State private var scale: CGFloat = 1
#State private var isDisable = false
var body: some View {
VStack {
Button(
action: {
self.isDisable = true
withAnimation(
.linear(duration: 1)
) {
scale = scale - 0.1
}
},
label: {
Text("Tap Me")
}
)
.disabled(
isDisable
)
RectangleView()
.scaleEffect(scale)
.onAnimationCompleted(for: scale) {
isDisable = false
}
}
}
}
struct RectangleView: View {
var body: some View {
Rectangle().fill(
Color.blue
)
.frame(
width:200,
height: 150
)
}
}
/// An animatable modifier that is used for observing animations for a given animatable value.
struct AnimationCompletionObserverModifier<Value>: AnimatableModifier where Value: VectorArithmetic {
/// While animating, SwiftUI changes the old input value to the new target value using this property. This value is set to the old value until the animation completes.
var animatableData: Value {
didSet {
notifyCompletionIfFinished()
}
}
/// The target value for which we're observing. This value is directly set once the animation starts. During animation, `animatableData` will hold the oldValue and is only updated to the target value once the animation completes.
private var targetValue: Value
/// The completion callback which is called once the animation completes.
private var completion: () -> Void
init(observedValue: Value, completion: #escaping () -> Void) {
self.completion = completion
self.animatableData = observedValue
targetValue = observedValue
}
/// Verifies whether the current animation is finished and calls the completion callback if true.
private func notifyCompletionIfFinished() {
guard animatableData == targetValue else { return }
/// Dispatching is needed to take the next runloop for the completion callback.
/// This prevents errors like "Modifying state during view update, this will cause undefined behavior."
DispatchQueue.main.async {
self.completion()
}
}
func body(content: Content) -> some View {
/// We're not really modifying the view so we can directly return the original input value.
return content
}
}
extension View {
/// Calls the completion handler whenever an animation on the given value completes.
/// - Parameters:
/// - value: The value to observe for animations.
/// - completion: The completion callback to call once the animation completes.
/// - Returns: A modified `View` instance with the observer attached.
func onAnimationCompleted<Value: VectorArithmetic>(for value: Value, completion: #escaping () -> Void) -> ModifiedContent<Self, AnimationCompletionObserverModifier<Value>> {
return modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion))
}
}
Add delay.
{
isDisable = true
withAnimation(
.linear(duration: 1)
) {
scale = scale - 0.1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { //< Here
isDisable = false
}
}
Button {
let duration: Double = 1
isDisable = true
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
isDisable = false
}
withAnimation(.linear(duration: duration)) {
scale -= 0.1
}
} label: {
Text("Tap Me")
}

Bound preference _ tried to update multiple times per frame

My goal is to create a similar app interface like in "Things 3", where user can move a cursor to then create a task at where cursor was dropped. Since I m planning to have multiple views to be able to react at whether the cursor is hovering over them, I decided to build a simple system.
First the view that will have a cursor needs to be wrapped in a Selecting class:
struct Selecting<Content: View, Cursor: View>: View {
#State var cursorRect: CGRect?
let cursorView: Cursor
let content: Content
let initialOffset: CGSize
#State var dragging = false
#State var offset = CGSize()
init(initialOffset: CGSize = .zero, #ViewBuilder cursorView: () -> Cursor, #ViewBuilder content: () -> Content) {
self.content = content()
self.cursorView = cursorView()
self.initialOffset = initialOffset
}
var body: some View {
ZStack {
content
.environment(\.cursorRect, cursorRect)
cursorView
.background(
GeometryReader {
Color.clear
.preference(key: CursorRectKey.self, value: $0.frame(in: .global))
}
.onPreferenceChange(CursorRectKey.self) { rect in cursorRect = dragging ? rect : nil }
)
.highPriorityGesture(
DragGesture(minimumDistance: 10, coordinateSpace: .global)
.onChanged {
self.offset = $0.translation
dragging = true
}
.onEnded { _ in
self.offset = .zero
dragging = false
}
)
.offset(initialOffset + offset)
}
}
}
private struct CursorRectKey: PreferenceKey {
static let defaultValue = CGRect()
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
private struct SelectedKey: PreferenceKey {
static let defaultValue = false
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = nextValue()
}
}
private struct PointerKey: EnvironmentKey {
static let defaultValue: CGRect? = nil
}
private extension EnvironmentValues {
var cursorRect: CGRect? {
get { self[PointerKey.self] }
set { self[PointerKey.self] = newValue }
}
}
E.g. like this
Selecting(initialOffset: .init(width: 60, height: 60), cursorView: {
Image(systemName: "pencil")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30)
.foregroundColor(.white)
.background(
Circle()
.frame(width: 60, height: 60)
.foregroundColor(.green)
)
}) {
List(dates, id: \.self) { date in
DayView(currentProgram: currentProgram, events: $events, date: date)
}
}
Then the view that needs to respond to the hovering needs to have a .selectable modifier
extension View {
func selectable(hovered: Binding<Bool?>) -> some View {
Selectable(hovered: hovered) { self }
.border(Color.red, width: 1)
}
}
struct Selectable<Content: View>: View {
let content: Content
#Binding var hovered: Bool?
init(hovered: Binding<Bool?>, #ViewBuilder content: () -> Content) {
self._hovered = hovered
self.content = content()
}
#Environment(\.cursorRect) var cursorRect
var body: some View {
content
.background(
GeometryReader { proxy in
let child = proxy.frame(in: .global)
Color.clear
.preference(key: HoveredKey.self, value: cursorRect == nil ? nil : child.contains(cursorRect!.origin))
}
)
.onPreferenceChange(HoveredKey.self) { hovered in self.hovered = hovered }
}
}
private struct HoveredKey: PreferenceKey {
typealias Value = Bool?
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}
E.g. like this:
struct Title: View {
#State var hovered: Bool?
var body: some View {
VStack {
Text("Hello there")
.selectable(hovered: $hovered)
if hovered == true {
Text("Hovering")
}
}
}
}
The problem is that I get this bizarre error "Bound preference HoveredKey tried to update multiple times per frame" and I don't understand how to solve it and why it comes up. During debug, for some reason the hovered variable in Selectable is changed multiple times during a frame, just as the error states.
Alright solved. The problem was in how i was deciding whether a view is hovered or not. I was passing the coordindates of the cursor to the child, then check if it's contained in the child's rect.
However, now i do the opposite, I send the children's rects to the parent (Selecting), where then it's determined who is hovered, then the selected child's id is sent to children. This approach works perfectly
Here is the new code:
import SwiftUI
extension View {
func selectable(hovered: Binding<Bool?>) -> some View {
return Selectable(hovered: hovered) { self }
}
}
struct Selectable<Content: View>: View {
let content: Content
#Binding var hovered: Bool?
#State var id = UUID()
init(hovered: Binding<Bool?>, #ViewBuilder content: () -> Content) {
self._hovered = hovered
self.content = content()
}
#Environment(\.selectedChild) private var selectedChild
var body: some View {
content
.background(
GeometryReader { proxy in
let child = proxy.frame(in: .global)
Color.clear
.preference(key: ChildInfoKey.self, value: [id: child])
.preference(key: BoolKey.self, value: selectedChild == id)
}
)
.onPreferenceChange(BoolKey.self) { hovered = $0 }
}
}
private struct BoolKey: PreferenceKey {
typealias Value = Bool
static var defaultValue = false
static func reduce(value: inout Value, nextValue: () -> Value) { value = nextValue() }
}
private struct ChildInfoKey: PreferenceKey {
typealias Value = [UUID: CGRect]
static var defaultValue: Value = [:]
static func reduce(value: inout Value, nextValue: () -> Value) { value.merge(nextValue()) { $1 } }
}
struct Selecting<Content: View, Cursor: View>: View {
#State var cursorRect: CGRect?
let cursorView: Cursor
let content: Content
let initialOffset: CGSize
#State var dragging = false
#State var offset = CGSize()
#State var children = [UUID: CGRect]()
#State var selectedChild: UUID?
init(initialOffset: CGSize = .zero, #ViewBuilder cursorView: () -> Cursor, #ViewBuilder content: () -> Content) {
self.content = content()
self.cursorView = cursorView()
self.initialOffset = initialOffset
}
var body: some View {
ZStack {
content
.onPreferenceChange(ChildInfoKey.self) { children in self.children = children }
.environment(\.selectedChild, selectedChild)
cursorView
.background(
GeometryReader {
Color.clear
.preference(key: CursorRectKey.self, value: $0.frame(in: .global))
}
)
.onPreferenceChange(CursorRectKey.self) { rect in
let children = self.children
let selectedChild = dragging ? children.first { $0.1.contains(rect.origin) }?.key : nil
self.selectedChild = selectedChild
print("\(selectedChild == nil ? "nil" : "\(selectedChild!)")")
}
.highPriorityGesture(
DragGesture(minimumDistance: 10, coordinateSpace: .global)
.onChanged {
self.offset = $0.translation
dragging = true
}
.onEnded { _ in
self.offset = .zero
dragging = false
}
)
.offset(initialOffset + offset)
}
}
}
private struct CursorRectKey: PreferenceKey {
static let defaultValue = CGRect()
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
private struct SelectedChildKey: EnvironmentKey {
static var defaultValue: UUID? = nil
}
private extension EnvironmentValues {
var selectedChild: UUID? {
get { self[SelectedChildKey.self] }
set { self[SelectedChildKey.self] = newValue }
}
}

SwiftUI | Using onDrag and onDrop to reorder Items within one single LazyGrid?

I was wondering if it is possible to use the View.onDrag and View.onDrop to add drag and drop reordering within one LazyGrid manually?
Though I was able to make every Item draggable using onDrag, I have no idea how to implement the dropping part.
Here is the code I was experimenting with:
import SwiftUI
//MARK: - Data
struct Data: Identifiable {
let id: Int
}
//MARK: - Model
class Model: ObservableObject {
#Published var data: [Data]
let columns = [
GridItem(.fixed(160)),
GridItem(.fixed(160))
]
init() {
data = Array<Data>(repeating: Data(id: 0), count: 100)
for i in 0..<data.count {
data[i] = Data(id: i)
}
}
}
//MARK: - Grid
struct ContentView: View {
#StateObject private var model = Model()
var body: some View {
ScrollView {
LazyVGrid(columns: model.columns, spacing: 32) {
ForEach(model.data) { d in
ItemView(d: d)
.id(d.id)
.frame(width: 160, height: 240)
.background(Color.green)
.onDrag { return NSItemProvider(object: String(d.id) as NSString) }
}
}
}
}
}
//MARK: - GridItem
struct ItemView: View {
var d: Data
var body: some View {
VStack {
Text(String(d.id))
.font(.headline)
.foregroundColor(.white)
}
}
}
Thank you!
SwiftUI 2.0
Here is completed simple demo of possible approach (did not tune it much, `cause code growing fast as for demo).
Important points are: a) reordering does not suppose waiting for drop, so should be tracked on the fly; b) to avoid dances with coordinates it is more simple to handle drop by grid item views; c) find what to where move and do this in data model, so SwiftUI animate views by itself.
Tested with Xcode 12b3 / iOS 14
import SwiftUI
import UniformTypeIdentifiers
struct GridData: Identifiable, Equatable {
let id: Int
}
//MARK: - Model
class Model: ObservableObject {
#Published var data: [GridData]
let columns = [
GridItem(.fixed(160)),
GridItem(.fixed(160))
]
init() {
data = Array(repeating: GridData(id: 0), count: 100)
for i in 0..<data.count {
data[i] = GridData(id: i)
}
}
}
//MARK: - Grid
struct DemoDragRelocateView: View {
#StateObject private var model = Model()
#State private var dragging: GridData?
var body: some View {
ScrollView {
LazyVGrid(columns: model.columns, spacing: 32) {
ForEach(model.data) { d in
GridItemView(d: d)
.overlay(dragging?.id == d.id ? Color.white.opacity(0.8) : Color.clear)
.onDrag {
self.dragging = d
return NSItemProvider(object: String(d.id) as NSString)
}
.onDrop(of: [UTType.text], delegate: DragRelocateDelegate(item: d, listData: $model.data, current: $dragging))
}
}.animation(.default, value: model.data)
}
}
}
struct DragRelocateDelegate: DropDelegate {
let item: GridData
#Binding var listData: [GridData]
#Binding var current: GridData?
func dropEntered(info: DropInfo) {
if item != current {
let from = listData.firstIndex(of: current!)!
let to = listData.firstIndex(of: item)!
if listData[to].id != current!.id {
listData.move(fromOffsets: IndexSet(integer: from),
toOffset: to > from ? to + 1 : to)
}
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
return DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
self.current = nil
return true
}
}
//MARK: - GridItem
struct GridItemView: View {
var d: GridData
var body: some View {
VStack {
Text(String(d.id))
.font(.headline)
.foregroundColor(.white)
}
.frame(width: 160, height: 240)
.background(Color.green)
}
}
Edit
Here is how to fix the never disappearing drag item when dropped outside of any grid item:
struct DropOutsideDelegate: DropDelegate {
#Binding var current: GridData?
func performDrop(info: DropInfo) -> Bool {
current = nil
return true
}
}
struct DemoDragRelocateView: View {
...
var body: some View {
ScrollView {
...
}
.onDrop(of: [UTType.text], delegate: DropOutsideDelegate(current: $dragging))
}
}
Here's my solution (based on Asperi's answer) for those who seek for a generic approach for ForEach where I abstracted the view away:
struct ReorderableForEach<Content: View, Item: Identifiable & Equatable>: View {
let items: [Item]
let content: (Item) -> Content
let moveAction: (IndexSet, Int) -> Void
// A little hack that is needed in order to make view back opaque
// if the drag and drop hasn't ever changed the position
// Without this hack the item remains semi-transparent
#State private var hasChangedLocation: Bool = false
init(
items: [Item],
#ViewBuilder content: #escaping (Item) -> Content,
moveAction: #escaping (IndexSet, Int) -> Void
) {
self.items = items
self.content = content
self.moveAction = moveAction
}
#State private var draggingItem: Item?
var body: some View {
ForEach(items) { item in
content(item)
.overlay(draggingItem == item && hasChangedLocation ? Color.white.opacity(0.8) : Color.clear)
.onDrag {
draggingItem = item
return NSItemProvider(object: "\(item.id)" as NSString)
}
.onDrop(
of: [UTType.text],
delegate: DragRelocateDelegate(
item: item,
listData: items,
current: $draggingItem,
hasChangedLocation: $hasChangedLocation
) { from, to in
withAnimation {
moveAction(from, to)
}
}
)
}
}
}
The DragRelocateDelegate basically stayed the same, although I made it a bit more generic and safer:
struct DragRelocateDelegate<Item: Equatable>: DropDelegate {
let item: Item
var listData: [Item]
#Binding var current: Item?
#Binding var hasChangedLocation: Bool
var moveAction: (IndexSet, Int) -> Void
func dropEntered(info: DropInfo) {
guard item != current, let current = current else { return }
guard let from = listData.firstIndex(of: current), let to = listData.firstIndex(of: item) else { return }
hasChangedLocation = true
if listData[to] != current {
moveAction(IndexSet(integer: from), to > from ? to + 1 : to)
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
hasChangedLocation = false
current = nil
return true
}
}
And finally here is the actual usage:
ReorderableForEach(items: itemsArr) { item in
SomeFancyView(for: item)
} moveAction: { from, to in
itemsArr.move(fromOffsets: from, toOffset: to)
}
There was a few additional issues raised to the excellent solutions above, so here's what I could come up with on Jan 1st with a hangover (i.e. apologies for being less than eloquent):
If you pick a griditem and release it (to cancel), then the view is not reset
I added a bool that checks if the view had been dragged yet, and if it hasn't then it doesn't hide the view in the first place. It's a bit of a hack, because it doesn't really reset, it just postpones hiding the view until it knows that you want to drag it. I.e. if you drag really fast, you can see the view briefly before it's hidden.
If you drop a griditem outside the view, then the view is not reset
This one was partially addressed already, by adding the dropOutside delegate, but SwiftUI doesn't trigger it unless you have a background view (like a color), which I think caused some confusion. I therefore added a background in grey to illustrate how to properly trigger it.
Hope this helps anyone:
import SwiftUI
import UniformTypeIdentifiers
struct GridData: Identifiable, Equatable {
let id: String
}
//MARK: - Model
class Model: ObservableObject {
#Published var data: [GridData]
let columns = [
GridItem(.flexible(minimum: 60, maximum: 60))
]
init() {
data = Array(repeating: GridData(id: "0"), count: 50)
for i in 0..<data.count {
data[i] = GridData(id: String("\(i)"))
}
}
}
//MARK: - Grid
struct DemoDragRelocateView: View {
#StateObject private var model = Model()
#State private var dragging: GridData? // I can't reset this when user drops view ins ame location as drag started
#State private var changedView: Bool = false
var body: some View {
VStack {
ScrollView(.vertical) {
LazyVGrid(columns: model.columns, spacing: 5) {
ForEach(model.data) { d in
GridItemView(d: d)
.opacity(dragging?.id == d.id && changedView ? 0 : 1)
.onDrag {
self.dragging = d
changedView = false
return NSItemProvider(object: String(d.id) as NSString)
}
.onDrop(of: [UTType.text], delegate: DragRelocateDelegate(item: d, listData: $model.data, current: $dragging, changedView: $changedView))
}
}.animation(.default, value: model.data)
}
}
.frame(maxWidth:.infinity, maxHeight: .infinity)
.background(Color.gray.edgesIgnoringSafeArea(.all))
.onDrop(of: [UTType.text], delegate: DropOutsideDelegate(current: $dragging, changedView: $changedView))
}
}
struct DragRelocateDelegate: DropDelegate {
let item: GridData
#Binding var listData: [GridData]
#Binding var current: GridData?
#Binding var changedView: Bool
func dropEntered(info: DropInfo) {
if current == nil { current = item }
changedView = true
if item != current {
let from = listData.firstIndex(of: current!)!
let to = listData.firstIndex(of: item)!
if listData[to].id != current!.id {
listData.move(fromOffsets: IndexSet(integer: from),
toOffset: to > from ? to + 1 : to)
}
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
return DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
changedView = false
self.current = nil
return true
}
}
struct DropOutsideDelegate: DropDelegate {
#Binding var current: GridData?
#Binding var changedView: Bool
func dropEntered(info: DropInfo) {
changedView = true
}
func performDrop(info: DropInfo) -> Bool {
changedView = false
current = nil
return true
}
}
//MARK: - GridItem
struct GridItemView: View {
var d: GridData
var body: some View {
VStack {
Text(String(d.id))
.font(.headline)
.foregroundColor(.white)
}
.frame(width: 60, height: 60)
.background(Circle().fill(Color.green))
}
}
Goal: Reordering Items in HStack
I was trying to figure out how to leverage this solution in SwiftUI for macOS when dragging icons to re-order a horizontal set of items. Thanks to #ramzesenok and #Asperi for the overall solution. I added a CGPoint property along with their solution to achieve the desired behavior. See the animation below.
Define the point
#State private var drugItemLocation: CGPoint?
I used in dropEntered, dropExited, and performDrop DropDelegate functions.
func dropEntered(info: DropInfo) {
if current == nil {
current = item
drugItemLocation = info.location
}
guard item != current,
let current = current,
let from = icons.firstIndex(of: current),
let toIndex = icons.firstIndex(of: item) else { return }
hasChangedLocation = true
drugItemLocation = info.location
if icons[toIndex] != current {
icons.move(fromOffsets: IndexSet(integer: from), toOffset: toIndex > from ? toIndex + 1 : toIndex)
}
}
func dropExited(info: DropInfo) {
drugItemLocation = nil
}
func performDrop(info: DropInfo) -> Bool {
hasChangedLocation = false
drugItemLocation = nil
current = nil
return true
}
For a full demo, I created a gist using Playgrounds
Here is how you implement the on drop part. But remember the ondrop can allow content to be dropped in from outside the app if the data conforms to the UTType. More on UTTypes.
Add the onDrop instance to your lazyVGrid.
LazyVGrid(columns: model.columns, spacing: 32) {
ForEach(model.data) { d in
ItemView(d: d)
.id(d.id)
.frame(width: 160, height: 240)
.background(Color.green)
.onDrag { return NSItemProvider(object: String(d.id) as NSString) }
}
}.onDrop(of: ["public.plain-text"], delegate: CardsDropDelegate(listData: $model.data))
Create a DropDelegate to handling dropped content and the drop location with the given view.
struct CardsDropDelegate: DropDelegate {
#Binding var listData: [MyData]
func performDrop(info: DropInfo) -> Bool {
// check if data conforms to UTType
guard info.hasItemsConforming(to: ["public.plain-text"]) else {
return false
}
let items = info.itemProviders(for: ["public.plain-text"])
for item in items {
_ = item.loadObject(ofClass: String.self) { data, _ in
// idea is to reindex data with dropped view
let index = Int(data!)
DispatchQueue.main.async {
// id of dropped view
print("View Id dropped \(index)")
}
}
}
return true
}
}
Also the only real useful parameter of performDrop is info.location a CGPoint of the drop location, Mapping a CGPoint to the view you want to replace seems unreasonable. I would think the OnMove would be a better option and would make moving your data/Views a breeze. I was unsuccessful to get OnMove working within a LazyVGrid.
As LazyVGrid are still in beta and are bound to change. I would abstain from use on more complex tasks.
I came with a bit different approach that works fine on macOS. Instead of using .onDrag and .onDrop Im using .gesture(DragGesture) with a helper class and modifiers.
Here are helper objects (just copy this to the new file):
// Helper class for dragging objects inside LazyVGrid.
// Grid items must be of the same size
final class DraggingManager<Entry: Identifiable>: ObservableObject {
let coordinateSpaceID = UUID()
private var gridDimensions: CGRect = .zero
private var numberOfColumns = 0
private var numberOfRows = 0
private var framesOfEntries = [Int: CGRect]() // Positions of entries views in coordinate space
func setFrameOfEntry(at entryIndex: Int, frame: CGRect) {
guard draggedEntry == nil else { return }
framesOfEntries[entryIndex] = frame
}
var initialEntries: [Entry] = [] {
didSet {
entries = initialEntries
calculateGridDimensions()
}
}
#Published // Currently displayed (while dragging)
var entries: [Entry]?
var draggedEntry: Entry? { // Detected when dragging starts
didSet { draggedEntryInitialIndex = initialEntries.firstIndex(where: { $0.id == draggedEntry?.id }) }
}
var draggedEntryInitialIndex: Int?
var draggedToIndex: Int? { // Last index where device was dragged to
didSet {
guard let draggedToIndex, let draggedEntryInitialIndex, let draggedEntry else { return }
var newArray = initialEntries
newArray.remove(at: draggedEntryInitialIndex)
newArray.insert(draggedEntry, at: draggedToIndex)
withAnimation {
entries = newArray
}
}
}
func indexForPoint(_ point: CGPoint) -> Int {
let x = max(0, min(Int((point.x - gridDimensions.origin.x) / gridDimensions.size.width), numberOfColumns - 1))
let y = max(0, min(Int((point.y - gridDimensions.origin.y) / gridDimensions.size.height), numberOfRows - 1))
return max(0, min(y * numberOfColumns + x, initialEntries.count - 1))
}
private func calculateGridDimensions() {
let allFrames = framesOfEntries.values
let rows = Dictionary(grouping: allFrames) { frame in
frame.origin.y
}
numberOfRows = rows.count
numberOfColumns = rows.values.map(\.count).max() ?? 0
let minX = allFrames.map(\.minX).min() ?? 0
let maxX = allFrames.map(\.maxX).max() ?? 0
let minY = allFrames.map(\.minY).min() ?? 0
let maxY = allFrames.map(\.maxY).max() ?? 0
let width = (maxX - minX) / CGFloat(numberOfColumns)
let height = (maxY - minY) / CGFloat(numberOfRows)
let origin = CGPoint(x: minX, y: minY)
let size = CGSize(width: width, height: height)
gridDimensions = CGRect(origin: origin, size: size)
}
}
struct Draggable<Entry: Identifiable>: ViewModifier {
#Binding
var originalEntries: [Entry]
let draggingManager: DraggingManager<Entry>
let entry: Entry
#ViewBuilder
func body(content: Content) -> some View {
if let entryIndex = originalEntries.firstIndex(where: { $0.id == entry.id }) {
let isBeingDragged = entryIndex == draggingManager.draggedEntryInitialIndex
let scale: CGFloat = isBeingDragged ? 1.1 : 1.0
content.background(
GeometryReader { geometry -> Color in
draggingManager.setFrameOfEntry(at: entryIndex, frame: geometry.frame(in: .named(draggingManager.coordinateSpaceID)))
return .clear
}
)
.scaleEffect(x: scale, y: scale)
.gesture(
dragGesture(
draggingManager: draggingManager,
entry: entry,
originalEntries: $originalEntries
)
)
}
else {
content
}
}
func dragGesture<Entry: Identifiable>(draggingManager: DraggingManager<Entry>, entry: Entry, originalEntries: Binding<[Entry]>) -> some Gesture {
DragGesture(coordinateSpace: .named(draggingManager.coordinateSpaceID))
.onChanged { value in
// Detect start of dragging
if draggingManager.draggedEntry?.id != entry.id {
withAnimation {
draggingManager.initialEntries = originalEntries.wrappedValue
draggingManager.draggedEntry = entry
}
}
let point = draggingManager.indexForPoint(value.location)
if point != draggingManager.draggedToIndex {
draggingManager.draggedToIndex = point
}
}
.onEnded { value in
withAnimation {
originalEntries.wrappedValue = draggingManager.entries!
draggingManager.entries = nil
draggingManager.draggedEntry = nil
draggingManager.draggedToIndex = nil
}
}
}
}
extension View {
// Allows item in LazyVGrid to be dragged between other items.
func draggable<Entry: Identifiable>(draggingManager: DraggingManager<Entry>, entry: Entry, originalEntries: Binding<[Entry]>) -> some View {
self.modifier(Draggable(originalEntries: originalEntries, draggingManager: draggingManager, entry: entry))
}
}
Now to use it in view you have to do few things:
Create a draggingManager that is a StateObject
Create a var that exposes either real array you are using or temporary array used by draggingManager during dragging.
Apply coordinateSpace from draggingManager to the container (LazyVGrid)
That way draggingManager only modifies its copy of the array during the process, and you can update the original after dragging is done.
struct VirtualMachineSettingsDevicesView: View {
#ObservedObject
var vmEntity: VMEntity
#StateObject
private var devicesDraggingManager = DraggingManager<VMDeviceInfo>()
// Currently displaying devices - different during dragging.
private var displayedDevices: [VMDeviceInfo] { devicesDraggingManager.entries ?? vmEntity.config.devices }
var body: some View {
Section("Devices") {
LazyVGrid(columns: [.init(.adaptive(minimum: 64, maximum: 64))], alignment: .leading, spacing: 20) {
Group {
ForEach(displayedDevices) { device in
Button(action: { configureDevice = device }) {
device.label
.draggable(
draggingManager: devicesDraggingManager,
entry: device,
originalEntries: $vmEntity.config.devices
)
}
}
Button(action: { configureNewDevice = true }, label: { Label("Add device", systemImage: "plus") })
}
.labelStyle(IconLabelStyle())
}
.coordinateSpace(name: devicesDraggingManager.coordinateSpaceID)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.buttonStyle(.plain)
}
}

Debounced Property Wrapper

After spending some time creating a #Debounced property wrapper I'm not happy with the readability of the code. To understand what's going on you really need to understand how a Property wrapper works and the concept of the wrappedvalue and projectedvalue. This is the Property Wrapper:
#propertyWrapper
class Debounced<Input: Hashable> {
private var delay: Double
private var _value: Input
private var function: ((Input) -> Void)?
private weak var timer: Timer?
public init(wrappedValue: Input, delay: Double) {
self.delay = delay
self._value = wrappedValue
}
public var wrappedValue: Input {
get {
return _value
}
set(newValue) {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false, block: { [weak self] _ in
self?._value = newValue
self?.timer?.invalidate()
self?.function?(newValue)
})
}
}
public var projectedValue: ((Input) -> Void)? {
get {
return function
}
set(newValue) {
function = newValue
}
}
}
The property wrapper is being used like this:
#Debounced(delay: 0.4) var text: String? = nil
override func viewDidLoad() {
super.viewDidLoad()
self.$text = { text in
print(text)
}
}
It works as it should. Every time the text property is being set, the print function is being called. And if the value is updated more than once within 0.4 seconds then the function will only be called once.
BUT in terms of simplicity and readability, I think its better just creating a Debouncer class like this: https://github.com/webadnan/swift-debouncer.
What do you think? Is there a better way to create this property wrapper?
It works as it should ... In that case, just use it!
Hm ... but how to use it? In reality, it is not very flexible, especially till compiler claims "Multiple property wrappers are not supported" :-)
If your goal is to use it in UIKit or SwiftUI app, I suggest you different approach.
Lets try some minimalistic, but fully working SwiftUI example
//
// ContentView.swift
// tmp031
//
// Created by Ivo Vacek on 26/01/2020.
// Copyright © 2020 Ivo Vacek. NO rights reserved.
//
import SwiftUI
import Combine
class S: ObservableObject {
#Published var text: String = ""
#Published var debouncedText: String = ""
private var store = Set<AnyCancellable>()
init(delay: Double) {
$text
.debounce(for: .seconds(delay), scheduler: RunLoop.main)
.sink { [weak self] (s) in
self?.debouncedText = s
}.store(in: &store)
}
}
struct ContentView: View {
#ObservedObject var model = S(delay: 2)
var body: some View {
List {
Color.clear
Section(header: Text("Direct")) {
Text(model.text).font(.title)
}
Section(header: Text("Debounced")) {
Text(model.debouncedText).font(.title)
}
Section(header: Text("Source")) {
TextField("type here", text: $model.text).font(.title)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You still can subscribe to model.$debouncedText which is Publisher as many times, as you need. And if you like to use your own action to be performed, no problem as well!
model.$debouncedText
.sink { (s) in
doSomethingWithDebouncedValue(s)
}
Example application usage
UPDATE: if you not able to use Combine, but you like similar syntax ...
First define the protokol
protocol Debounce: class {
associatedtype Value: Hashable
var _value: Value { get set }
var _completions: [(Value)->Void] { get set}
var _delay: TimeInterval { get set }
var _dw: DispatchWorkItem! { get set }
func debounce(completion: #escaping (Value)->Void)
}
and default implementation of debounce function. The idea is, to use debounce the same way, as .publisher.sink() on Combine. _debounce is "internal" implementation of debouncing functionality. It compare current and "delay" old value and if they are equal, do the job.
extension Debounce {
func debounce(completion: #escaping (Value)->Void) {
_completions.append(completion)
}
func _debounce(newValue: Value, delay: TimeInterval, completions: [(Value)->Void]) {
if _dw != nil {
_dw.cancel()
}
var dw: DispatchWorkItem!
dw = DispatchWorkItem(block: { [weak self, newValue, completions] in
if let s = self, s._value == newValue {
for completion in completions {
completion(s._value)
}
}
dw = nil
})
_dw = dw
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: dw)
}
}
Now we have all componets of our property wrapper.
#propertyWrapper class Debounced<T: Hashable> {
final class Debouncer: Debounce {
typealias Value = T
var _completions: [(T) -> Void] = []
var _delay: TimeInterval
var _value: T {
willSet {
_debounce(newValue: newValue, delay: _delay, completions: _completions)
}
}
var _dw: DispatchWorkItem!
init(_value: T, _delay: TimeInterval) {
self._value = _value
self._delay = _delay
}
}
var wrappedValue: T {
get { projectedValue._value }
set { projectedValue._value = newValue }
}
var projectedValue: Debouncer
init(wrappedValue: T, delay: TimeInterval) {
projectedValue = Debouncer(_value: wrappedValue, _delay: delay)
}
deinit {
print("deinit")
}
}
lets try it
do {
struct S {
#Debounced(delay: 0.2) var value: Int = 0
}
let s = S()
print(Date(), s.value, "initial")
s.$value.debounce { (i) in
print(Date(), i, "debounced A")
}
s.$value.debounce { (i) in
print(Date(), i, "debounced B")
}
var t = 0.0
(0 ... 8).forEach { (i) in
let dt = Double.random(in: 0.0 ... 0.6)
t += dt
DispatchQueue.main.asyncAfter(deadline: .now() + t) { [t] in
s.value = i
print(s.value, t)
}
}
}
which prints something like
2020-02-04 09:53:11 +0000 0 initial
0 0.46608517831539165
2020-02-04 09:53:12 +0000 0 debounced A
2020-02-04 09:53:12 +0000 0 debounced B
1 0.97078412234771
2 1.1756938500918692
3 1.236562020385944
4 1.4076127046937024
2020-02-04 09:53:13 +0000 4 debounced A
2020-02-04 09:53:13 +0000 4 debounced B
5 1.9313412744029004
6 2.1617775513150366
2020-02-04 09:53:14 +0000 6 debounced A
2020-02-04 09:53:14 +0000 6 debounced B
7 2.6665465865810205
8 2.9287734023206418
deinit

Resources