SwiftUI: Two-finger swipe ( scroll ) gesture - swift

I'm interested in 2-finger swipe ( scroll ) gesture.
Not two-finger drag, but 2-finger swipe (without press). Like used in Safari to scroll up and down.
As I see noone of basic gestures will work for this: TapGesture - is not; LongPressGesture - not; DragGesture - not; MagnificationGesture - not; RotationGesture - not;
Have anyone some ideas how to do this?
I need at least direction to look at.
This is MacOS project
And by the way I cannot use UI classes in my project, I cannot re-made project to catalist

With due respect to #duncan-c 's answer, the more effective way is to use the NSResponder's scrollWheel(with: NSEvent) mechanism to track two-finger scrolling (one finger on the Apple Mouse).
However it's only available under NSView, so you need to integrate it into SwiftUI using NSRepresentableView.
Here is a complete set of working code that scrolls the main image using the scroll wheel. The code uses delegates and callbacks to pass the scroll event back up the chain into SwiftUI:
//
// ContentView.swift
// ScrollTest
//
// Created by TR Solutions on 6/9/21.
//
import SwiftUI
/// How the view passes events back to the representable view.
protocol ScrollViewDelegateProtocol {
/// Informs the receiver that the mouse’s scroll wheel has moved.
func scrollWheel(with event: NSEvent);
}
/// The AppKit view that captures scroll wheel events
class ScrollView: NSView {
/// Connection to the SwiftUI view that serves as the interface to our AppKit view.
var delegate: ScrollViewDelegateProtocol!
/// Let the responder chain know we will respond to events.
override var acceptsFirstResponder: Bool { true }
/// Informs the receiver that the mouse’s scroll wheel has moved.
override func scrollWheel(with event: NSEvent) {
// pass the event on to the delegate
delegate.scrollWheel(with: event)
}
}
/// The SwiftUI view that serves as the interface to our AppKit view.
struct RepresentableScrollView: NSViewRepresentable, ScrollViewDelegateProtocol {
/// The AppKit view our SwiftUI view manages.
typealias NSViewType = ScrollView
/// What the SwiftUI content wants us to do when the mouse's scroll wheel is moved.
private var scrollAction: ((NSEvent) -> Void)?
/// Creates the view object and configures its initial state.
func makeNSView(context: Context) -> ScrollView {
// Make a scroll view and become its delegate
let view = ScrollView()
view.delegate = self;
return view
}
/// Updates the state of the specified view with new information from SwiftUI.
func updateNSView(_ nsView: NSViewType, context: Context) {
}
/// Informs the representable view that the mouse’s scroll wheel has moved.
func scrollWheel(with event: NSEvent) {
// Do whatever the content view wants
// us to do when the scroll wheel moved
if let scrollAction = scrollAction {
scrollAction(event)
}
}
/// Modifier that allows the content view to set an action in its context.
func onScroll(_ action: #escaping (NSEvent) -> Void) -> Self {
var newSelf = self
newSelf.scrollAction = action
return newSelf
}
}
/// Our SwiftUI content view that we want to be able to scroll.
struct ContentView: View {
/// The scroll offset -- when this value changes the view will be redrawn.
#State var offset: CGSize = CGSize(width: 0.0, height: 0.0)
/// The SwiftUI view that detects the scroll wheel movement.
var scrollView: some View {
// A view that will update the offset state variable
// when the scroll wheel moves
RepresentableScrollView()
.onScroll { event in
offset = CGSize(width: offset.width + event.deltaX, height: offset.height + event.deltaY)
}
}
/// The body of our view.
var body: some View {
// What we want to be able to scroll using offset(),
// overlaid (must be on top or it can't get the scroll event!)
// with the view that tracks the scroll wheel.
Image(systemName:"applelogo")
.scaleEffect(20.0)
.frame(width: 200, height: 200, alignment: .center)
.offset(offset)
.overlay(scrollView)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Edit: Correcting my answer cover Mac OS
Scrolling up and down is a NSPanGestureRecognizer. That has a numberOfTouchesRequired property that lets you make it respond to 2 fingers if desired.
Mac OS does not have a swipe gesture recognizer.
The standard UISwipeGestureRecognizer does exactly what you want. Just set numberOfTouchesRequired to 2.
...Although I'm not sure mobile Safari uses swipe gestures. It might be a 2-finger drag with some special coding.

import Combine
#main
struct MyApp: App {
#State var subs = Set<AnyCancellable>() // Cancel onDisappear
#SceneBuilder
var body: some Scene {
WindowGroup {
SomeWindowView()
/////////////
// HERE!!!!!
/////////////
.onAppear { trackScrollWheel() }
}
}
}
/////////////
// HERE!!!!!
/////////////
extension MyApp {
func trackScrollWheel() {
NSApp.publisher(for: \.currentEvent)
.filter { event in event?.type == .scrollWheel }
.throttle(for: .milliseconds(200),
scheduler: DispatchQueue.main,
latest: true)
.sink {
if let event = $0 {
if event.deltaX > 0 { print("right") }
if event.deltaX < 0 { print("left") }
if event.deltaY > 0 { print("down") }
if event.deltaY < 0 { print("up") }
}
}
.store(in: &subs)
}
}

Related

Using UIScrollView correctly in SwiftUI

I wanted to implement pull to refresh for Scrollview of SwiftUi and so I tried to use the UIScrollView of UIKit to make use of its refreshControl. But I am getting a problem with the UIScrollview. There are several different views inside the scrollview and all of its data is fetched from network service using different API call and once the first api data is received the first view is shown inside the scrollview, similarly when second api data is received the second view is appended to the scrollview and similarly step by step all views are added to scrollview. Now in this process of step by step adding view, the scrollview doesn't get scrolled and its contents remain hiding above the navigationbar and below the bottom toolbar (may be the scrollView height takes full height of contents). But if i load the scrollView only after all the subview data is ready all subviews are loaded together then there is no issue. But i want to load the subviews as soon as i get the first data without waiting for all the data of every subviews to load completely.
The code that I have used is -
//UIScrollView
struct HomeScrollView: UIViewRepresentable {
private let uiScrollView: UIScrollView
init<Content: View>(#ViewBuilder content: () -> Content) {
let hosting = UIHostingController(rootView: content())
hosting.view.translatesAutoresizingMaskIntoConstraints = false
uiScrollView = UIScrollView()
uiScrollView.addSubview(hosting.view)
uiScrollView.showsVerticalScrollIndicator = false
uiScrollView.contentInsetAdjustmentBehavior = .never
let constraints = [
hosting.view.leadingAnchor.constraint(equalTo: uiScrollView.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: uiScrollView.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.topAnchor, constant: 0),
hosting.view.bottomAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.bottomAnchor, constant: 0),
hosting.view.widthAnchor.constraint(equalTo: uiScrollView.widthAnchor)
]
uiScrollView.addConstraints(constraints)
}
func makeCoordinator() -> Coordinator {
Coordinator(legacyScrollView: self)
}
func makeUIView(context: Context) -> UIScrollView {
uiScrollView.refreshControl = UIRefreshControl()
uiScrollView.refreshControl?.addTarget(context.coordinator, action:
#selector(Coordinator.handleRefreshControl), for: .valueChanged)
return uiScrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {}
class Coordinator: NSObject {
let legacyScrollView: HomeScrollView
init(legacyScrollView: HomeScrollView) {
self.legacyScrollView = legacyScrollView
}
#objc func handleRefreshControl(sender: UIRefreshControl) {
refreshData()
}
}
}
//SwiftUi inside view's body
var body: some View {
VStack {
HomeScrollView() {
ViewOne(data: modelDataOne)
ViewTwo(data: modelDataTwo)
ViewThree(data: modelDataThree)
...
}
}
}

Keyboard offset no longer functions in SwiftUI 2

So I have an ObservableObject that sets a published variable 'currentHeight' to the height of the keyboard:
import Foundation
import SwiftUI
class KeyboardResponder: ObservableObject {
#Published var currentHeight: CGFloat = 0
var _center: NotificationCenter
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)
}
#objc func keyBoardWillShow(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
withAnimation {
currentHeight = keyboardSize.height
}
}
print("the KEYBOARD HEIGHT IS \(self.currentHeight)")
}
#objc func keyBoardWillHide(notification: Notification) {
withAnimation {
currentHeight = 0
}
print("the KEYBOARD HEIGHT IS \(self.currentHeight)")
}
}
In my other views, I create an ObservedObject keyboardResponder and then inside the body of the view, I'll have for example some view where I set the vertical offset:
struct ViewName: View {
#ObservedObject var keyboardResponder = KeyboardResponder()
var body: some View {
GeometryReader { proxy in
VStack {
Text("this should be offset")
.offset(y: -keyboardResponder.currentHeight)
}.edgesIgnoringSafeArea(.all)
}
}
}
Before Xcode 12/SwiftUI 2 dropped, this worked like a charm but I think they changed something with how views refresh--anyone know what they changed and if there is a solution to my issue here?
EDIT: In my view, if I remove that edgesIgnoringSafeArea(.all), it sort of works, but weirdly. Essentially, the view can only move up to the point where all views are just in frame, it doesn't allow the entire view to shift up (and out of screen view)...I'll throw in a vid to show what I mean...
This is the link for when there are some more elements that take up most of the screen (so the offset is extremely small)
https://youtu.be/jpID11-rZDs
This is the link for when there are fewer elements, so the offset gets that much larger
https://youtu.be/MjqX1qDEA6E
If the GeometryReader is enclosing the view, that's what REALLY breaks it and stops it from responding at all. If I remove it, then it functions as needed...
As of the latest Swift, Xcode 12 and iOS14, I noticed that it is standard build in, when the textfield is not visible when typing. The screen is being risen with the keyboard height and shows the original field. Try this out with a scrollable view and 20 textfields for example.
Maybe you can get rid of your observableheight and do it without hard coding it.

Magnifier SwiftUI

I'm trying to render 2 SwiftUI views, in a VStack.
struct ContentView: View {
let content = // [...]
let magnifier = // [...]
var body: some View {
VStack {
self.content
self.magnifier
}
}
}
The first view (i.e., content) is a TextView, the second view (i.e., magnifier) is just a magnifier to the first view with a given scale and offset. This is basically the "zoom" functionality of some notes apps, Notability for example.
With UIKit I would have write a class Magnifier: UIView, and in its draw() function, something like
override func draw(_ rect: CGRect) {
let context = UIGraphicsGetCurrentContext()
// [...]
self.contentView.layer.render(in: context)
}
Can we do something like that with SwiftUI or do we have to use UIKit and UIViewRepresentable?

Detecting if a UIScrollView has the height to scroll

I have a UIScrollView that sometimes has enough contentHeight to scroll and sometimes doesn't. I also have a button that's predicated on the user scrolling to the bottom of the scroll view.
Without the user taking an action, how do I detect if the scroll view has the contentHeight to scroll so I can set the default isEnabled of the button appropriately?
Thanks!
You can create an extension on UIScrollView
extension UIScrollView {
var contentUntilBotttom: CGFloat {
return max(contentSize.height - frame.size.height - contentOffset.y, 0)
}
var isAtTheBottom: Bool {
return contentUntilBotttom == 0
}
}
In case you are using RxSwift you can observe changes like:
extension Reactive where Base == UIScrollView {
var isAtTheBottom: ControlEvent<Bool> {
let source = contentOffset
.map({ _ -> Bool in
return self.base.isAtTheBottom
})
return ControlEvent(events: source)
}
}
// So then subscribe to it
scrollView.rx.isAtTheBottom
.subscribe(onNext: { (isAtTheBottom) in
// Update anithing you need
})

TVOS 10 SpriteKit Focus Navigation default focused item

I am trying to update my SpriteKit games to use the new SKNode focus navigation feature, but I am having trouble to change the default focused item.
Essentially I have this code in my button class to support focus navigation
class Button: SKSpriteNode {
var isFocusable = true // easy way to disable focus incase menus are shown etc
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
userInteractionEnabled = true
}
// MARK: - Focus navigation
#if os(tvOS)
extension Button {
/// Can become focused
override var canBecomeFocused: Bool {
return isFocusable
}
/// Did update focus
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
if context.previouslyFocusedItem === self {
// some SKAction to reset the button to default settings
}
else if context.nextFocusedItem === self {
// some SKAction to scale the button up
}
}
}
#endif
Everything is working great, however by default the first button on the left side of the screen is focused.
I am trying to change this to another button but I cannot do it. I now you are supposed to use
override var preferredFocusEnvironments: [UIFocusEnvironment]...
// preferredFocusedView is depreciated
but I dont understand how and where to use this.
I tried adding this code to my menu scene to change the focused button from the default (shop button) to the play button thats on the right side of the screen.
class MenuScene: SKScene {
// left side of screen
lazy var shopButton: Button = self.childNode(withName: "shopButton")
// right side of screen
lazy var playButton: Button = self.childNode(withName: "playButton")
// Set preferred focus
open override var preferredFocusEnvironments: [UIFocusEnvironment] {
return [self.playButton]
}
}
and calling
setNeedsFocusUpdate()
updateFocusIfNeeded()
in didMoveToView but it doesn't work.
How can I change my default focused button in SpriteKit?
So I finally got this working thanks to this great article.
https://medium.com/folded-plane/tvos-10-getting-started-with-spritekit-and-focus-engine-53d8ef3b34f3#.pfwcw4u70
The main step I completely missed is that you have tell your GameViewController that your scenes are the preferred focus environment.
This essentially means that your SKScenes will handle the preferred focus instead of GameViewController.
In a SpriteKit game the SKScenes should handle the UI such as buttons using SpriteKit APIs such as SKLabelNodes, SKSpriteNodes etc. Therefore you need to pass the preferred focus to the SKScene.
class GameViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// default code to present your 1st SKScene.
}
}
#if os(tvOS)
extension GameViewController {
/// Tell GameViewController that the currently presented SKScene should always be the preferred focus environment
override var preferredFocusEnvironments: [UIFocusEnvironment] {
if let scene = (view as? SKView)?.scene {
return [scene]
}
return []
}
}
#endif
Your buttons should be a subclass of SKSpriteNode that you will use for all your buttons in your game. Use enums and give them different names/ identifiers to distinguish between them when they are pressed (checkout Apples sample game DemoBots).
class Button: SKSpriteNode {
var isFocusable = true // easy way to later turn off focus for your buttons e.g. when overlaying menus etc.
/// Can become focused
override var canBecomeFocused: Bool {
return isFocusable
}
/// Did update focus
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
if context.previouslyFocusedItem === self {
// SKAction to reset focus animation for unfocused button
}
if context.nextFocusedItem === self {
// SKAction to run focus animation for focused button
}
}
}
Than in your Start Scene you can set the focus environment to your playButton or any other button.
class StartScene: SKScene {
....
}
#if os(tvOS)
extension StartScene {
override var preferredFocusEnvironments: [UIFocusEnvironment] {
return [playButton]
}
}
#endif
If you overlay a menu or other UI in a scene you can do something like this e.g GameScene (move focus to gameMenuNode if needed)
class GameScene: SKScene {
....
}
#if os(tvOS)
extension GameScene {
override var preferredFocusEnvironments: [UIFocusEnvironment] {
if isGameMenuShowing { // add some check like this
return [gameMenuNode]
}
return [] // empty means scene itself
}
}
#endif
You will also have to tell your GameViewController to update its focus environment when you transition between SKScenes (e.g StartScene -> GameScene). This is especially important if you use SKTransitions, it took me a while to figure this out. If you use SKTransitions than the old and new scene are active during the transition, therefore the GameViewController will use the old scenes preferred focus environments instead of the new one which means the new scene will not focus correctly.
I do it like this every time I transition between scenes. You will have to use a slight delay or it will not work correctly.
...
view?.presentScene(newScene, transition: ...)
#if os(tvOS)
newScene.run(SKAction.wait(forDuration: 0.02)) { // wont work without delay
view?.window?.rootViewController?.setNeedsFocusUpdate()
view?.window?.rootViewController?.updateFocusIfNeeded()
}
#endif