In trying to implement a custom variation of HSplitView/VSplitView for macOS, I found it impossible to achieve correct behavior of the NSCursor while dragging the split view divider. Something else is resetting NSCursor to an arrow cursor when a SwiftUI view updates.
Moving the mouse over the divider View changes the NSCursor to a resize cursor. However, when the insertion is blinking, after 1/2 a second, on the blink the cursor changes back to an arrow.
Turning off "blink", then hovering over the divider, the NSCursor stays as a resize cursor. However, as soon as you click and drag to move the divider, again it changes back to an arrow cursor, flashing between the two NSCursor as you move the divider.
It seems like something in SwiftUI is doing this, but I can't tell.
Is it possible to implement a custom VSplitView within SwiftUI and achieve consistent behavior of the NSCursor?
Full code below
import AppKit
import SwiftUI
@main
struct NSCursorBugApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
let blinkTimer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
struct ContentView: View {
@State var position = 20.0
@State var opacity = 1.0
@State var blink = true
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Color.gray
.frame(height: position - 4)
.overlay{
Rectangle().frame(width: 1, height: 20).opacity(opacity).padding(.trailing, 200).padding(20)
}
PaneDivider(position: $position)
Color.cyan
Toggle("Blink", isOn: $blink)
}
.onReceive(blinkTimer) {_ in if blink { opacity = 1 - opacity } }
.frame(width: 500, height: 500)
.coordinateSpace(name: "stack")
}
}
struct PaneDivider: View {
@Binding var position: Double
let dividerSize = 8.0
let circleSize = 6.0
var body: some View {
Group {
Divider()
ZStack {
Rectangle().fill(Color(white: 0.99))
Circle().frame(width: circleSize, height: circleSize).foregroundColor(Color(white: 0.89))
}.frame(height: 8)
Divider()
}
#if os(macOS)
.background(NSCursorView())
#endif
.gesture(DragGesture(minimumDistance: 1, coordinateSpace: .named("stack"))
.onChanged { position = max(0, $0.location.y) })
}
}
/// Helper NSView as .background to SwiftUI PaneDivider View to set resize NSCursor when inside view bounds
class NSCursorPlatformView : NSView {
var horizontal = true
var trackingArea: NSTrackingArea? = nil
func clearTrackingAreas() {
if let trackingArea = trackingArea { removeTrackingArea(trackingArea) }
trackingArea = nil
}
override func updateTrackingAreas() {
clearTrackingAreas()
let area = NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow], owner: self, userInfo: nil)
addTrackingArea(area)
trackingArea = area
}
override func viewWillMove(toWindow newWindow: NSWindow?) {
if newWindow == nil { clearTrackingAreas() }
else { updateTrackingAreas() }
}
override func mouseEntered(with event: NSEvent) { updateCursor(with: event) }
override func mouseExited(with event: NSEvent) { updateCursor(with: event) }
override func mouseMoved(with event: NSEvent) { updateCursor(with: event) }
override func cursorUpdate(with event: NSEvent) {} // to stop system from resetting cursor after us
func updateCursor(with event: NSEvent) {
let p = convert(event.locationInWindow, from: nil)
if bounds.contains(p) {
(horizontal ? NSCursor.resizeUpDown : NSCursor.resizeLeftRight).set()
} else {
NSCursor.arrow.set()
}
}
}
struct NSCursorView: NSViewRepresentable {
var horizontal = true
func makeNSView(context: Context) -> NSCursorPlatformView {
let view = NSCursorPlatformView()
view.horizontal = horizontal
return view
}
func updateNSView(_ nsView: NSCursorPlatformView, context: Context) { }
}