SwiftUI stops updates during scrolling of List - swiftui

Given a List in SwiftUI, once panning begins, updating of views in the list seems to pause until the scrolling has been stopped. Is there a way to prevent this?
Consider the following code:
class Model: ObservableObject, Identifiable {
#Published var offset: CGFloat = 0
let id = UUID()
private var timer: Timer!
init() {
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { _ in
self.update()
})
}
func update() {
offset = CGFloat.random(in: 0...300)
}
}
struct ContentView: View {
#ObservedObject var model1 = Model()
#ObservedObject var model2 = Model()
#ObservedObject var model3 = Model()
#ObservedObject var model4 = Model()
var body: some View {
List {
ForEach([model1, model2, model3, model4]) {
Rectangle()
.foregroundColor(.red)
.frame(width: $0.offset, height: 30, alignment: .center)
.animation(.default)
}
}
}
}
Will result in this behaviour:

You could use GCD as in Asperi's answer, but that doesn't explain why your code didn't work.
The problem is that, while the scroll view is tracking your touch, it runs the run loop in the .tracking mode. But because you created your Timer using scheduledTimer(withTimeInterval:repeats:block:), the Timer is only set to run in the .default mode.
You could add the timer to all the common run loop modes (which include .tracking) like this:
RunLoop.main.add(timer, forMode: .common)
But I would probably use a Combine publisher instead, like this:
class Model: ObservableObject, Identifiable {
#Published var offset: CGFloat = 0
let id = UUID()
private var tickets: [AnyCancellable] = []
init() {
Timer.publish(every: 0.5, on: RunLoop.main, in: .common)
.autoconnect()
.map { _ in CGFloat.random(in: 0...300) }
.sink { [weak self] in self?.offset = $0 }
.store(in: &tickets)
}
}

This due to nature of Timer and RunLoop. Use instead GCD, like in below approach
init() {
var runner: (() -> ())?
runner = {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
if let self = self {
self.update()
runner?()
}
}
}
runner?()
}

Related

Problem With Bindings with an NSViewRepresentable hosted NSScrollView

I am trying to make a simple SWiftUI ScrollView where I can set and get the value of the ScrollView bounds offset via a binding. I have the following which compiles and works fine as a ScrollView but I am unable to actually set and get the offset and propagate it back to the ContentView where the ScrollView is hosted.
I have the following:
struct MyScrollView<Content>: NSViewRepresentable where Content: View {
private var content: Content
let offset: Binding<CGFloat>
init(offset: Binding<CGFloat>, #ViewBuilder content: () -> Content) {
self.content = content()
self.offset = offset
}
func makeNSView(context: NSViewRepresentableContext<MyScrollView>) ->TheScrollView {
let view = TheScrollView(offset: offset)
view.hasVerticalScroller = true
view.hasHorizontalScroller = true
let document = NSHostingView(rootView: content)
document.translatesAutoresizingMaskIntoConstraints = false
view.documentView = document
return view
}
func updateNSView(_ view: TheScrollView, context: NSViewRepresentableContext<MyScrollView>) {
}
}
class TheScrollView: NSScrollView, ObservableObject{
private var subscriptions: Set<AnyCancellable> = []
var offset: Binding<CGFloat>
init(offset: Binding<CGFloat>){
self.offset = offset
super.init(frame: .zero)
NotificationCenter.default
.publisher(for: NSScrollView.boundsDidChangeNotification, object: self.contentView.documentView)
.sink() { _ in
let view = self.contentView
print(view.bounds.origin.y) // <- I do get this
self.offset.wrappedValue = view.bounds.origin.y // This does nothing
}
.store(in: &subscriptions)
}
required init?(coder: NSCoder){
fatalError("init(coder:) has not been implemented")
}
}
MyScrollView is hosted in a contentView like this:
import SwiftUI
import Combine
struct ContentView: View{
#State var offset: CGFloat = 10.0{
didSet{
print("Offset \(offset)")
}
}
var body: some View{
MyScrollView(offset: $offset){
ZStack{
Rectangle().foregroundColor(.clear).frame(width: 1200, height: 1000)
Rectangle().foregroundColor(.blue).frame(width: 100, height: 100)
}
}
}
}
As you can see the offset value is passed from the #State var into MyScollView and then into TheScrollView, which is a subclass of NSScrollView. From there I have a simple notification to get the bounds change and set the binding. However setting the binding does nothing to the actual value in the binding and it definitely doesn't propagate back to the ContentView. Also, the address of offset changes up the hierarchy so it looks like I am passing a Binding to a Binding into TheScrollView rather than the original binding, but I cant seem to fix it.
Can anyone see what I am doing wrong?
It is State - it is updated when is used in body, so instead use like below:
struct ContentView: View{
#State var offset: CGFloat = 10.0
var body: some View {
VStack {
Text("Offset: \(offset)") // << here !!
MyScrollView(offset: $offset){
ZStack{
Rectangle().foregroundColor(.clear).frame(width: 1200, height: 1000)
Rectangle().foregroundColor(.blue).frame(width: 100, height: 100)
}
}
}
}
}

How to observe a published property via another observable 0bject - Swift Combine

[Edit]
I should point out that I am collecting data from a large number of sensors and having to pollute the view model, that is orchestrating this, with lots of #Published and subscriber code gets quite tedious and error prone.
I've also edited the code to be more representative of the actual problem.
[Original]
I'm trying to reduce the amount of code needed to observer a result from another class when using publishers. I would prefer to publish the result from a class that is generating the result instead of having to propagate it back to the calling class.
Here is a simple playground example showing the issue.
import SwiftUI
import Combine
class AnObservableObject: ObservableObject {
#Published var flag: Bool = false
private var timerPub: Publishers.Autoconnect<Timer.TimerPublisher>
private var timerSub: AnyCancellable?
init() {
timerPub = Timer.publish(every: 1, on: .current, in: .common)
.autoconnect()
}
func start() {
timerSub = timerPub.sink { [self] _ in
toggleFlag()
}
}
func stop() {
timerSub?.cancel()
}
func toggleFlag() {
flag.toggle()
}
}
class AnotherObservableObject: ObservableObject {
let ao = AnObservableObject()
func start() {
ao.start()
}
func stop() {
ao.stop()
}
}
struct MyView: View {
#StateObject var ao = AnotherObservableObject()
var body: some View {
VStack {
if ao.ao.flag {
Image(systemName: "flag").foregroundColor(.green)
}
HStack {
Button(action: {ao.start()}, label: {
Text("Toggle Flag")
})
Button(action: {ao.stop()}, label: {
Text("Stop")
})
}
}
.padding()
}
}
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
// Make a UIHostingController
let viewController = UIHostingController(rootView: MyView())
// Assign it to the playground's liveView
PlaygroundPage.current.liveView = viewController
let myView = MyView()
The Only way I have got this to work is to do this:
import SwiftUI
import Combine
class AnObservableObject: ObservableObject {
let flag = CurrentValueSubject<Bool, Never>(false)
private var subscriptions = Set<AnyCancellable>()
private var timerPub: Publishers.Autoconnect<Timer.TimerPublisher>
private var timerSub: AnyCancellable?
init() {
timerPub = Timer.publish(every: 1, on: .current, in: .common)
.autoconnect()
}
func start() {
timerSub = timerPub.sink { [self] _ in
toggleFlag()
}
}
func stop() {
timerSub?.cancel()
}
func flagPublisher() -> AnyPublisher<Bool, Never> {
return flag.eraseToAnyPublisher()
}
func toggleFlag() {
flag.value.toggle()
}
}
class AnotherObservableObject: ObservableObject {
let ao = AnObservableObject()
#Published var flag = false
init() {
let flagPublisher = ao.flagPublisher()
flagPublisher
.receive(on: DispatchQueue.main)
.assign(to: &$flag)
}
func start() {
ao.start()
}
func stop() {
ao.stop()
}
}
struct MyView: View {
#StateObject var ao = AnotherObservableObject()
var body: some View {
VStack {
if ao.flag {
Image(systemName: "flag").foregroundColor(.green)
}
HStack {
Button(action: {ao.start()}, label: {
Text("Toggle Flag")
})
Button(action: {ao.stop()}, label: {
Text("Stop")
})
}
}
.padding()
}
}
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
// Make a UIHostingController
let viewController = UIHostingController(rootView: MyView())
// Assign it to the playground's liveView
PlaygroundPage.current.liveView = viewController
let myView = MyView()
Thoughts?
You don't need an ObservableObject for this; you can observe a Timer directly in your View.
struct MyView: View {
#State private var flag: Bool = false
#State var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
if flag {
Image(systemName: "flag").foregroundColor(.green)
}
HStack {
Button(action: {start()}, label: {
Text("Toggle Flag")
})
Button(action: {stop()}, label: {
Text("Stop")
})
}
.onReceive(timer) { _ in
flag.toggle()
}
}
.padding()
}
func start() {
timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
}
func stop() {
timer.upstream.connect().cancel()
}
}

How can I create a pulsating opacity wave in a list with SwiftUI?

I have tried with the following code
struct ContentView: View {
#State var show = false
var body: some View {
ScrollView {
LazyVStack(alignment: .center, spacing: 0) {
ForEach(1...100, id: \.self) { index in
if self.show {
Text("Placeholder \(index)")
.padding(24)
.opacity(1)
.transition(
AnyTransition.opacity.animation(
Animation
.easeOut(duration: 0.6)
.delay(Double(index) * 0.15)
.repeatForever(autoreverses: true)
)
)
}
}
}
}.onAppear {
self.show = true
}
}
}
This works fine for the first iteration, but for the next iterations the delay is accumulated wrongly.
Wanted effect (first one on the left). Result (last one on the right).
Here would be a working solution.
The idea is to handle repeating forever manually with a timer.
The scrolling also works:
One just has to wait a bit, since the delay increases linearly with Double(index)*0.15.
Code:
import SwiftUI
import Combine
final class AnimationManager: ObservableObject {
#Published var timer: AnyCancellable!
#Published var show: Bool = false
init() {
timer = Timer.publish(every: 0.75, on: .main, in: .common)
.autoconnect()
.sink { _ in
self.show.toggle()
}
}
}
struct ContentView: View {
#StateObject private var animationManager = AnimationManager()
var body: some View {
ScrollView {
LazyVStack(alignment: .center, spacing: 0) {
ForEach(1...100, id: \.self) { index in
Text("Placeholder \(index)")
.padding(24)
.opacity(animationManager.show ? 1.0 : 0.1)
.animation(Animation.easeOut(duration: 0.6).delay(Double(index)*0.15))
}
}
}
}
}
Additional
You might want to use a VStack instead of LazyVStack. This would get rid of the increasing delay of elements down in the list since they all appear immediately. I don't know what your desired effect is.
Wrapping this in a UIView seems to work and give desired effect
struct PulsatingView<Content: View>: UIViewRepresentable {
var maxOpacity = 0.7
var minOpacity = 0.2
var duration = 0.5
var delay = 0.05
var content: Content
func makeUIView(context: Context) -> ViewContainer<Content> {
let view = ViewContainer(child: content)
return view
}
func updateUIView(_ container: ViewContainer<Content>, context: Context) {
let anim = CABasicAnimation()
anim.fromValue = minOpacity
anim.toValue = maxOpacity
anim.duration = duration
anim.autoreverses = true
anim.timingFunction = .init(name: .easeInEaseOut)
anim.repeatCount = Float.greatestFiniteMagnitude
anim.timeOffset = -delay
anim.keyPath = "opacity"
container.child.rootView = content
container.layer.add(anim, forKey: "pulsating")
}
}
class ViewContainer<Content: View>: UIView {
var child: UIHostingController<Content>
init(child: Content) {
self.child = UIHostingController(rootView: child)
super.init(frame: .zero)
addSubview(self.child.view)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
child.view.bounds = bounds
child.view.center = CGPoint(x: bounds.width/2.0, y: bounds.height/2.0)
}
override var intrinsicContentSize: CGSize {
child.view.sizeThatFits(CGSize(width: Double.greatestFiniteMagnitude, height: Double.greatestFiniteMagnitude))
}
}
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack(alignment: .center, spacing: 0) {
ForEach(1...1000, id: \.self) { index in
PulsatingView(
delay: Double(index) * 0.15,
content: {
Text("Placeholder \(index)")
.padding(24)
}()
)
}
}
}
}
}
But there's some bugs with vertical placement when used in conjunction with LazyVStack 🤨

How to use LongPressGesture and Combine with Timer to ‘pump' values to model?

This is for MacOS. I am trying to figure out how I can pump values to my model while the user holds down a custom button. Basically I am trying to recreate a MouseDown/MouseUp combo with a timer firing in between. It doesn’t seem possible with just a LongPressGesture so I have been experimenting with Combine and a Timer but with only partial success. I have the following:
import SwiftUI
import Combine
var cancellable: Cancellable?
struct ContentView: View {
var body: some View {
ZStack{
FastForwardButton().frame(width: 40, height: 40)
}.frame(width: 200, height: 200)
}
}
struct FastForwardButton: View {
var timer = Timer.publish(every: 0.2, on: .main, in: .common)
#GestureState private var isPressed = false
// #State var cancellable: Cancellable?
var body: some View {
Rectangle()
.gesture(
LongPressGesture(minimumDuration: 4)
.updating($isPressed, body: { (currentState, state, transaction) in
if self.isPressed == false{
state = currentState
print("Timer Started")
cancellable = self.timer.connect()
}
})
)
.onReceive(timer) { time in
// Do Something here eg myModel.pump()
print("The time is \(time)")
if self.isPressed == false{
print("Timer Cancelled")
cancellable?.cancel()
// cancellable = nil
}
}
}
}
The above works one time. I get:
"Timer Started"
"The Time is xxx"
.....
"The Time is xxx"
"Timer Cancelled"
But the second press I just get:
"Timer Started"
With no further actions
Note I also had to temporarily move the reference to cancellable outside the View as I got a warning about modifying the View while updating.
Can anyone figure out why the .onReceive closure is only called once? Thanks!
try this (copy - paste - run) macOS
import SwiftUI
import Combine
class Model: ObservableObject {
var timerPublisher: Timer.TimerPublisher?
var handle: AnyCancellable?
var startstop: Cancellable?
func start() {
timerPublisher = Timer.publish(every: 0.5, on: RunLoop.main, in: .default)
handle = timerPublisher?.sink(receiveValue: {
print($0)
})
startstop = timerPublisher?.connect()
print("start")
}
func stop() {
startstop?.cancel()
print("stop")
}
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
Button(action: {
self.model.start()
}) {
Text("Start")
}
Button(action: {
self.model.stop()
}) {
Text("Stop")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
it shows two buttons and you can start stop publishing ... (Apsperi is right that once publisher is canceled, it is not usable again. It is true for any publisher, not specific for Timer.Publisher.
if you like to "publish" some changes in your model you can do it some standard way
func start() {
timerPublisher = Timer.publish(every: 0.5, on: RunLoop.main, in: .default)
handle = timerPublisher?.sink(receiveValue: {
print($0)
self.objectWillChange.send()
})
startstop = timerPublisher?.connect()
print("start")
}
The question is why not simply use .autoconnect() ?? The answer is flexibility, think about this model
class Model: ObservableObject {
var timerPublisher: Timer.TimerPublisher?
var handle: AnyCancellable?
var startstop: Cancellable?
func createTimerPublisher() {
timerPublisher = Timer.publish(every: 0.5, on: RunLoop.main, in: .default)
handle = timerPublisher?.sink(receiveValue: {
print($0)
self.objectWillChange.send()
})
}
init() {
createTimerPublisher()
}
func start() {
startstop = timerPublisher?.connect()
print("start")
}
func stop() {
startstop?.cancel()
print("stop")
// or create it conditionaly for later use
createTimerPublisher()
}
}
UPDATE where mouse-up and mouse-down on some View starts / stops the timer
import SwiftUI
import Combine
class Model: ObservableObject {
var timerPublisher: Timer.TimerPublisher?
var handle: AnyCancellable?
var startstop: Cancellable?
func createTimerPublisher() {
timerPublisher = Timer.publish(every: 0.5, on: RunLoop.main, in: .default)
handle = timerPublisher?.sink(receiveValue: {
print($0)
self.objectWillChange.send()
})
}
init() {
createTimerPublisher()
}
func start() {
// if
startstop = timerPublisher?.connect()
print("start")
}
func stop() {
startstop?.cancel()
startstop = nil
print("stop")
// or create it conditionaly for later use
createTimerPublisher()
}
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
Button(action: {
self.model.start()
}) {
Text("Start")
}
Button(action: {
self.model.stop()
}) {
Text("Stop")
}
MouseUpDownRepresentable(content: Rectangle()).environmentObject(model)
.frame(width: 50, height: 50)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
class MouseUpDownViewClass<Content>: NSHostingView<Content> where Content : View {
let model: Model
required init(model: Model, rootView: Content) {
self.model = model
super.init(rootView: rootView)
}
#objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
required init(rootView: Content) {
fatalError("init(rootView:) has not been implemented")
}
override func mouseUp(with event: NSEvent) {
print("mouse up")
model.stop()
}
override func mouseDown(with event: NSEvent) {
print("mouse down")
model.start()
}
}
struct MouseUpDownRepresentable<Content>: NSViewRepresentable where Content: View {
#EnvironmentObject var model: Model
let content: Content
func makeNSView(context: Context) -> NSHostingView<Content> {
return MouseUpDownViewClass(model: model, rootView: self.content)
}
func updateNSView(_ nsView: NSHostingView<Content>, context: Context) {
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
If it looks too complex, you can create it solely with SwiftUI gesture. The trick uses different gesture recognition
struct TouchView: View {
#State var pressed = false
var body: some View {
Circle().fill(pressed ? Color.yellow : Color.orange)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged({ (touch) in
if self.pressed == false {
self.pressed = true
print("start")
}
})
.onEnded({ (touch) in
print("stop")
self.pressed = false
})
)
}
}

How to make the bottom button follow the keyboard display in SwiftUI

With the help of the following, I was able to follow the button on the keyboard display.
However, animation cannot be applied well.
How to show complete List when keyboard is showing up in SwiftUI
import SwiftUI
import Combine
import UIKit
class KeyboardResponder: ObservableObject {
let willset = PassthroughSubject<CGFloat, Never>()
private var _center: NotificationCenter
#Published var currentHeight: CGFloat = 0
var keyboardDuration: TimeInterval = 0
init(center: NotificationCenter = .default) {
_center = center
_center.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
_center.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
deinit {
_center.removeObserver(self)
}
#objc func keyBoardWillShow(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
currentHeight = keyboardSize.height
guard let duration:TimeInterval = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { return }
keyboardDuration = duration
}
}
#objc func keyBoardWillHide(notification: Notification) {
currentHeight = 0
}
}
import SwiftUI
struct Content: View {
#ObservedObject var keyboard = KeyboardResponder()
var body: some View {
VStack {
Text("text")
Spacer()
NavigationLink(destination: SubContentView()) {
Text("Done")
}
}
.padding(.bottom, keyboard.currentHeight)
animation(Animation.easeInOut(duration: keyboard.keyboardDuration))
}
}
enter image description here
Your main problem, is that you are using an implicit animation. Not only it may be animating things you may not want to animate, but also, you should never apply .animation() on containers. Of the few warnings in SwiftUI's documentation, this is one of them:
Use this modifier on leaf views rather than container views. The
animation applies to all child views within this view; calling
animation(_:) on a container view can lead to unbounded scope.
Source: https://developer.apple.com/documentation/swiftui/view/3278508-animation
The modified code removes the implicit .animation() call and replaces it with two implicit withAnimation closures.
I also replaced keyboardFrameEndUserInfoKey with keyboardFrameEndUserInfoKey, second calls were giving useless geometry.
class KeyboardResponder: ObservableObject {
let willset = PassthroughSubject<CGFloat, Never>()
private var _center: NotificationCenter
#Published var currentHeight: CGFloat = 0
var keyboardDuration: TimeInterval = 0
init(center: NotificationCenter = .default) {
_center = center
_center.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
_center.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
deinit {
_center.removeObserver(self)
}
#objc func keyBoardWillShow(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
guard let duration:TimeInterval = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { return }
keyboardDuration = duration
withAnimation(.easeInOut(duration: duration)) {
self.currentHeight = keyboardSize.height
}
}
}
#objc func keyBoardWillHide(notification: Notification) {
guard let duration:TimeInterval = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { return }
withAnimation(.easeInOut(duration: duration)) {
currentHeight = 0
}
}
}
struct ContentView: View {
#ObservedObject var keyboard = KeyboardResponder()
var body: some View {
return VStack {
Text("text \(keyboard.currentHeight)")
TextField("Enter text", text: .constant(""))
Spacer()
NavigationLink(destination: Text("SubContentView()")) {
Text("Done")
}
}
.padding(.bottom, keyboard.currentHeight)
// .animation(Animation.easeInOut(duration: keyboard.keyboardDuration))
}
}