SwiftUI Asymmetric Transition delay is not working - swiftui

I am trying to create an animation for presenting and dismissing a group of objects. I have:
if self.showSignInButtons {
Group {
Button(action: {}) { ... }
HStack { ... }
Button(action: {}) { ... }
}.transition(
AnyTransition.signInButtonTransition(
offset: geometry.size.height,
duration: 1.4,
delay: 50.0)
)
}
Here is the definition of the custom AnyTransition animation
public extension AnyTransition {
static func signInButtonTransition(offset: CGFloat, duration: Double, delay: Double) -> AnyTransition {
let insertion = AnyTransition.offset(y: offset)
.animation(Animation.easeOut(duration: duration).delay(delay))
let removal = AnyTransition.offset(y: offset).animation(.default)
return .asymmetric(insertion: insertion, removal: removal)
}
}
My goal is to delay the insertion animation to create a sequence, but I don't want there to be a delay when the view is dismissed. The problem is the asymmetric animation is using the default animation despite the added animation modifier. Is there any reason the delay and duration animation on the insertion are being ignored?

Related

Observe frame changes in SwiftUI

I have view that can be dragged and dropped on top of other views (lets say categories). To detect which category view I'm on top of, I store their frames in a frames array, which happens in onAppear of their invisible overlays. (This is based on Paul Hudsons implementation in this tutorial).
This works all nice and well, except when the position of those views change, e.g. in device orientation or windows resizing on iPad. This of course doesn't trigger onAppear, so the frames don't match anymore.
HStack() {
ForEach(categories) { category in
ZStack {
Circle()
Rectangle()
.foregroundColor(.clear)
.overlay(
GeometryReader { geo in
Color.clear
.onAppear {
categoryFrames[index(for: category)] = geo.frame(in: .global)
}
}
)
}
}
}
So any idea how to update the frames in those instances or how to differently observe them would be welcome.
It is possible to read views frames dynamically during refresh using view preferences, so you don't care about orientation, because have actual frames every time view is redrawn.
Here is a draft of approach.
Introduce model for view preference key:
struct ItemRec: Equatable {
let i: Int // item index
let p: CGRect // item position frame
}
struct ItemPositionsKey: PreferenceKey {
typealias Value = [ItemRec]
static var defaultValue = Value()
static func reduce(value: inout Value, nextValue: () -> Value) {
value.append(contentsOf: nextValue())
}
}
and now your code (assuming #State private var categoryFrames = [Int, CGRect]())
HStack() {
ForEach(categories) { category in
ZStack {
Circle()
Rectangle()
.foregroundColor(.clear)
.background( // << prefer background to avoid any side effect
GeometryReader { geo in
Color.clear.preference(key: ItemPositionsKey.self,
value: [ItemRec(i: index(for: category), p: geo.frame(in: .global))])
}
)
}
}
.onPreferenceChange(ItemPositionsKey.self) {
// actually you can use this listener at any this view hierarchy level
// and possibly use directly w/o categoryFrames state
for item in $0 {
categoryFrames[item.i] = item.p
}
}
}
I had a similar problem and this post inspired me in finding a solution. So maybe this will be useful to someone else.
Just assign to the onChange modifier the same you did to onAppear and set it to fire when geo.size changes.
HStack() {
ForEach(categories) { category in
ZStack {
Circle()
Rectangle()
.foregroundColor(.clear)
.overlay(
GeometryReader { geo in
Color.clear
.onAppear {
categoryFrames[index(for: category)] = geo.frame(in: .global)
}
.onChange(of: geo.size) { _ in
categoryFrames[index(for: category)] = geo.frame(in: .global)
}
}
)
}
}
}

SwiftUI NavigationLink double click on List MacOS

Can anyone think how to call an action when double clicking a NavigationLink in a List in MacOS? I've tried adding onTapGesture(count:2) but it does not have the desired effect and overrides the ability of the link to be selected reliably.
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: Item(itemDetail: item)) {
ItemRow(itemRow: item) //<-my row view
}.buttonStyle(PlainButtonStyle())
.simultaneousGesture(TapGesture(count:2)
.onEnded {
print("double tap")
})
}
}
}
}
EDIT:
I've set up a tag/selection in the NavigationLink and can now double or single click the content of the row. The only trouble is, although the itemDetail view is shown, the "active" state with the accent does not appear on the link. Is there a way to either set the active state (highlighted state) or extend the NavigationLink functionality to accept double tap as well as a single?
#State var selection:String?
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: Item(itemDetail: item), tag: item.id, selection: self.$selection) {
ItemRow(itemRow: item) //<-my row view
}.onTapGesture(count:2) { //<- Needed to be first!
print("doubletap")
}.onTapGesture(count:1) {
self.selection = item.id
}
}
}
}
}
Here's another solution that seems to work the best for me. It's a modifier that adds an NSView which does the actual handling. Works in List even with selection:
extension View {
/// Adds a double click handler this view (macOS only)
///
/// Example
/// ```
/// Text("Hello")
/// .onDoubleClick { print("Double click detected") }
/// ```
/// - Parameters:
/// - handler: Block invoked when a double click is detected
func onDoubleClick(handler: #escaping () -> Void) -> some View {
modifier(DoubleClickHandler(handler: handler))
}
}
struct DoubleClickHandler: ViewModifier {
let handler: () -> Void
func body(content: Content) -> some View {
content.background {
DoubleClickListeningViewRepresentable(handler: handler)
}
}
}
struct DoubleClickListeningViewRepresentable: NSViewRepresentable {
let handler: () -> Void
func makeNSView(context: Context) -> DoubleClickListeningView {
DoubleClickListeningView(handler: handler)
}
func updateNSView(_ nsView: DoubleClickListeningView, context: Context) {}
}
class DoubleClickListeningView: NSView {
let handler: () -> Void
init(handler: #escaping () -> Void) {
self.handler = handler
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
if event.clickCount == 2 {
handler()
}
}
}
https://gist.github.com/joelekstrom/91dad79ebdba409556dce663d28e8297
I've tried all these solutions but the main issue is using gesture or simultaneousGesture overrides the default single tap gesture on the List view which selects the item in the list. As such, here's a simple method I thought of to retain the default single tap gesture (select row) and handle a double tap separately.
struct ContentView: View {
#State private var monitor: Any? = nil
#State private var hovering = false
#State private var selection = Set<String>()
let fruits = ["apple", "banana", "plum", "grape"]
var body: some View {
List(fruits, id: \.self, selection: $selection) { fruit in
VStack {
Text(fruit)
.frame(maxWidth: .infinity, alignment: .leading)
.clipShape(Rectangle()) // Allows the hitbox to be the entire word not the if you perfectly press the text
}
.onHover {
hovering = $0
}
}
.onAppear {
monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) {
if $0.clickCount == 2 && hovering { // Checks if mouse is actually hovering over the button or element
print("Double Tap!") // Run action
}
return $0
}
}
.onDisappear {
if let monitor = monitor {
NSEvent.removeMonitor(monitor)
}
}
}
}
This works if you just need to single tap to select and item, but only do something if the user double taps. If you want to handle a single tap and a double tap, there still remains the problem of single tap running when its a double tap. A potential work around would be to capture and delay the single tap action by a few hundred ms and cancel it if it was a double tap action
Use simultaneous gesture, like below (tested with Xcode 11.4 / macOS 10.15.5)
NavigationLink(destination: Text("View One")) {
Text("ONE")
}
.buttonStyle(PlainButtonStyle()) // << required !!
.simultaneousGesture(TapGesture(count: 2)
.onEnded { print(">> double tap")})
or .highPriorityGesture(... if you need double-tap has higher priority
Looking for a similar solution I tried #asperi answer, but had the same issue with tappable areas as the original poster. After trying many variations the following is working for me:
#State var selection: String?
...
NavigationLink(destination: HistoryListView(branch: string), tag: string, selection: self.$selection) {
Text(string)
.gesture(
TapGesture(count:1)
.onEnded({
print("Tap Single")
selection = string
})
)
.highPriorityGesture(
TapGesture(count:2)
.onEnded({
print("Tap Double")
})
)
}

What's causing SwiftUI nested View items jumpy animation after the initial drawing?

I've recently encountered an issue in a container View that has a nested list of View items that use a repeatForever animation, that works fine when firstly drew and jumpy after a sibling item is added dynamically.
The list of View is dynamically generated from an ObservableObject property, and its represented here as Loop. It's generated after a computation that takes place in a background thread (AVAudioPlayerNodeImpl.CompletionHandlerQueue).
The Loop View animation has a duration that equals a dynamic duration property value of its passed parameter player. Each Loop has its own values, that may or not be the same in each sibling.
When the first Loop View is created the animation works flawlessly but becomes jumpy after a new item is included in the list. Which means, that the animation works correctly for the tail item (the last item in the list, or the newest member) and the previous wrongly.
From my perspective, it seems related to how SwiftUI is redrawing and there's a gap in my knowledge, that lead to an implementation that causes the animation states to scatter. The question is what is causing this or how to prevent this from happening in the future?
I've minimised the implementation, to improve clarity and focus on the subject.
Let's take a look into the Container View:
import SwiftUI
struct ContentView: View {
#EnvironmentObject var engine: Engine
fileprivate func Loop(duration: Double, play: Bool) -> some View {
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
.overlay(
Circle()
.trim(
from: 0,
to: play ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0,
lineCap: .round,
lineJoin: .round)
)
.animation(
self.audioEngine.isPlaying ?
Animation
.linear(duration: duration)
.repeatForever(autoreverses: false) :
.none
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
)
}
.frame(width: 100, height: 100)
.padding()
}
var body: some View {
VStack {
ForEach (0 ..< self.audioEngine.players.count, id: \.self) { index in
HStack {
self.Loop(duration: self.engine.players[index].duration, play: self.engine.players[index].isPlaying)
}
}
}
}
}
In the Body you'll find a ForEach that watches a list of Players, a #Published property from Engine.
Have a look onto the Engine class:
Class Engine: ObservableObject {
#Published var players = []
func record() {
...
}
func stop() {
...
self.recorderCompletionHandler()
}
func recorderCompletionHandler() {
...
let player = self.createPlayer(...)
player.play()
DispatchQueue.main.async {
self.players.append(player)
}
}
func createPlayer() {
...
}
}
Finally, a small video demo to showcase the issue that is worth more than words:
For this particular example, the last item has a duration that is double the duration of the previous two, that have the same duration each. Although the issue happens regardless of this exemplified state.
Would like to mention that the start time or trigger time is the same for all, the .play a method called in sync!
Edited
Another test after following good practices provided by #Ralf Ebert, with a slight change given my requirements, toggle the play state, which unfortunately causes the same issue, so thus far this does seem to be related with some principle in SwiftUI that is worth learning.
A modified version for the version kindly provided by #Ralf Ebert:
// SwiftUIPlayground
import SwiftUI
struct PlayerLoopView: View {
#ObservedObject var player: MyPlayer
var body: some View {
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
.overlay(
Circle()
.trim(
from: 0,
to: player.isPlaying ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
)
.animation(
player.isPlaying ?
Animation
.linear(duration: player.duration)
.repeatForever(autoreverses: false) :
.none
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
)
}
.frame(width: 100, height: 100)
.padding()
}
}
struct PlayersProgressView: View {
#ObservedObject var engine = Engine()
var body: some View {
NavigationView {
VStack {
ForEach(self.engine.players) { player in
HStack {
Text("Player")
PlayerLoopView(player: player)
}
}
}
.navigationBarItems(trailing:
VStack {
Button("Add Player") {
self.engine.addPlayer()
}
Button("Play All") {
self.engine.playAll()
}
Button("Stop All") {
self.engine.stopAll()
}
}.padding()
)
}
}
}
class MyPlayer: ObservableObject, Identifiable {
var id = UUID()
#Published var isPlaying: Bool = false
var duration: Double = 1
func play() {
self.isPlaying = true
}
func stop() {
self.isPlaying = false
}
}
class Engine: ObservableObject {
#Published var players = [MyPlayer]()
func addPlayer() {
let player = MyPlayer()
players.append(player)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
player.isPlaying = true
}
}
func stopAll() {
self.players.forEach { $0.stop() }
}
func playAll() {
self.players.forEach { $0.play() }
}
}
struct PlayersProgressView_Previews: PreviewProvider {
static var previews: some View {
PlayersProgressView()
}
}
The following demo was created by following the steps (the demo only shows after the stop all to keep it under 2mb maximum image upload in Stack Overflow):
- Add player
- Add player
- Add player
- Stop All (*the animations played well this far)
- Play All (*same issue as previously documented)
- Add player (*the tail player animation works fine)
Found an article reporting a similar issue:
https://horberg.nu/2019/10/15/a-story-about-unstoppable-animations-in-swiftui/
I'll have to find a different approach instead of using .repeatForever
You need to make sure that no view update (triggered f.e. by a change like adding a new player) causes 'Loop' to be re-evaluated again because this could reset the animation.
In this example, I would:
make the player Identifiable so SwiftUI can keep track of the objects (var id = UUID() suffices), then you can use ForEach(self.engine.players) and SwiftUI can keep track of the Player -> View association.
make the player itself an ObservableObject and create a PlayerLoopView instead of the Loop function in your example:
struct PlayerLoopView: View {
#ObservedObject var player: Player
var body: some View {
ZStack {
Circle()
// ...
}
}
That's imho the most reliable way to prevent state updates to mess with your animation.
See here for a runnable example: https://github.com/ralfebert/SwiftUIPlayground/blob/master/SwiftUIPlayground/Views/PlayersProgressView.swift
This problem seems to be generated with the original implementation, where the .animation method takes a conditional and that's what causes the jumpiness.
If we decide not and instead keep the desired Animation declaration and only toggle the animation duration it works fine!
As follows:
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
Circle()
.trim(
from: 0,
to: player.isPlaying ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
)
.animation(
Animation
.linear(duration: player.isPlaying ? player.duration : 0.0)
.repeatForever(autoreverses: false)
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
}
.frame(width: 100, height: 100)
.padding()
Obs: The third element duration is 4x longer, just for testing
The result as desired:

How to disable / modify a button using SwiftUI?

I want to create a POC using SwiftUI and CoreML. I use a simple button to call some function (called test here). This function is pretty heavy as it performs a CoreML inference, and it can take up to a minute to compute.
I have several problems:
The button is still active even when the computation is ongoing. In other words, if I click the button several times before the processing of the first click is finished, the processing will be performed several times. I want to disable the button as long as the processing is ongoing.
I tried to modify the button's appearance to signify the user that the processing is ongoing. In the example bellow, I change the button color to red before calling the test function, and I change it back to blue when the processing is over. But it doesn't work.
In the code bellow, the test function is just sleeping for 5 seconds to simulate the CoreML inference.
func test() -> Void {
print("Start processing")
sleep(5)
print("End processing")
}
struct ContentView: View {
#State private var buttonColor : Color = .blue
var body: some View {
VStack {
Button(action: {
self.buttonColor = .red
test()
self.buttonColor = .blue
}) {
Text("Start")
.font(.title)
.padding(.horizontal, 40)
.padding(.vertical, 5)
.background(self.buttonColor)
.foregroundColor(.white)
}
}
}
}
I guess this problem is very straight forward for most of you. I just can't find the correct search keywords to solve it by myself. Thanks for your help.
Here is possible approach (see also comments in code). Tested & works with Xcode 11.2 / iOS 13.2.
struct ContentView: View {
#State private var buttonColor : Color = .blue
var body: some View {
VStack {
Button(action: {
self.buttonColor = .red
DispatchQueue.global(qos: .background).async { // do in background
test()
DispatchQueue.main.async {
self.buttonColor = .blue // work with UI only in main
}
}
}) {
Text("Start")
.font(.title)
.padding(.horizontal, 40)
.padding(.vertical, 5)
.background(self.buttonColor)
.foregroundColor(.white)
}
.disabled(self.buttonColor == .red) // disabled while calculating
}
}
}

Get onAppear behaviour from list in ScrollView in SwiftUI

When creating a List view onAppear triggers for elements in that list the way you would expect: As soon as you scroll to that element the onAppear triggers. However, I'm trying to implement a horizontal list like this
ScrollView(.horizontal) {
HStack(spacing: mySpacing) {
ForEach(items) { item in
MyView(item: item)
.onAppear { \\do something }
}
}
}
Using this method the onAppear triggers for all items at once, that is to say: immediately, but I want the same behavior as for a List view. How would I go about doing this? Is there a manual way to trigger onAppear, or control when views load?
Why I want to achieve this: I have made a custom Image view that loads an image from an URL only when it appears (and substitutes a placeholder in the mean time), this works fine for a List view, but I'd like it to also work for my horizontal 'list'.
As per SwiftUI 2.0 (XCode 12 beta 1) this is finally natively solved:
In a LazyHStack (or any other grid or stack with the Lazy prefix) elements will only initialise (and therefore trigger onAppear) when they appear on screen.
Here is possible approach how to do this (tested/worked with Xcode 11.2 / iOS 13.2)
Demo: (just show dynamically first & last visible cell in scrollview)
A couple of important View extensions
extension View {
func rectReader(_ binding: Binding<CGRect>, in space: CoordinateSpace) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let rect = geometry.frame(in: space)
DispatchQueue.main.async {
binding.wrappedValue = rect
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
extension View {
func ifVisible(in rect: CGRect, in space: CoordinateSpace, execute: #escaping (CGRect) -> Void) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let frame = geometry.frame(in: space)
if frame.intersects(rect) {
execute(frame)
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
And a demo view of how to use them with cell views being in scroll view
struct TestScrollViewOnVisible: View {
#State private var firstVisible: Int = 0
#State private var lastVisible: Int = 0
#State private var visibleRect: CGRect = .zero
var body: some View {
VStack {
HStack {
Text("<< \(firstVisible)")
Spacer()
Text("\(lastVisible) >> ")
}
Divider()
band()
}
}
func band() -> some View {
ScrollView(.horizontal) {
HStack(spacing: 10) {
ForEach(0..<50) { i in
self.cell(for: i)
.ifVisible(in: self.visibleRect, in: .named("my")) { rect in
print(">> become visible [\(i)]")
// do anything needed with visible rects, below is simple example
// (w/o taking into account spacing)
if rect.minX <= self.visibleRect.minX && self.firstVisible != i {
DispatchQueue.main.async {
self.firstVisible = i
}
} else
if rect.maxX >= self.visibleRect.maxX && self.lastVisible != i {
DispatchQueue.main.async {
self.lastVisible = i
}
}
}
}
}
}
.coordinateSpace(name: "my")
.rectReader(self.$visibleRect, in: .named("my"))
}
func cell(for idx: Int) -> some View {
RoundedRectangle(cornerRadius: 10)
.fill(Color.yellow)
.frame(width: 80, height: 60)
.overlay(Text("\(idx)"))
}
}
I believe what you want to achieve can be done with LazyHStack.
ScrollView {
LazyVStack {
ForEach(1...100, id: \.self) { value in
Text("Row \(value)")
.onAppear {
// Write your code for onAppear here.
}
}
}
}