SwiftUI gestures in the toolbar area ignored - swift

I'd like to implement a custom slider SwiftUI component and put it on the toolbar area of a SwiftUI Mac app. However the gesture of the control gets ignored as the system's window moving gesture takes priority. This problem does not occur for the system UI controls, like Slider or Button.
How to fix the code below so the slider works in the toolbar area as well, not just inside the window similar to the default SwiftUI components?
struct MySlider: View {
#State var offset: CGFloat = 0.0
var body: some View {
GeometryReader { gr in
let thumbSize = gr.size.height
let maxValue = (gr.size.width - thumbSize) / 2.0
let gesture = DragGesture(minimumDistance: 0).onChanged { v in
self.offset = max(min(v.translation.width, maxValue), -maxValue)
}
ZStack {
Capsule()
Circle()
.foregroundColor(Color.yellow)
.frame(width: thumbSize, height: thumbSize)
.offset(x: offset)
.highPriorityGesture(gesture)
}
}.frame(width: 100, height: 20)
}
}
struct ContentView: View {
#State var value = 0.5
var body: some View {
MySlider()
.toolbar {
MySlider()
Slider(value: $value).frame(width: 100, height: 20)
}.frame(width: 500, height: 100)
}
}

Looks like design limitation (or not implemented yet feature - Apple does not see such view as user interaction capable item).
A possible workaround is to wrap you active element into button style. The button as a container interpreted as user-interaction-able area but all drawing and handling is in your code.
Tested with Xcode 13.2 / macOS 12.2
Note: no changes in your slider logic
struct MySlider: View {
var body: some View {
Button("") {}.buttonStyle(SliderButtonStyle())
}
struct SliderButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
MySliderContent()
}
struct MySliderContent: View {
#State var offset: CGFloat = 0.0
var body: some View {
GeometryReader { gr in
let thumbSize = gr.size.height
let maxValue = (gr.size.width - thumbSize) / 2.0
let gesture = DragGesture(minimumDistance: 0).onChanged { v in
self.offset = max(min(v.translation.width, maxValue), -maxValue)
}
ZStack {
Capsule()
Circle()
.foregroundColor(Color.yellow)
.frame(width: thumbSize, height: thumbSize)
.offset(x: offset)
.highPriorityGesture(gesture)
}
}.frame(width: 100, height: 20)
}
}
}
}

Related

How can I make the Window change it's position on Screen of a mac with pure SwiftUI?

Is there a pure SwiftUI code for changing the position of Window in macOS SwiftUI? I know there is a way via AppKit codes and using NSApp.mainWindow and then using window.isMovableByWindowBackground = true, but my approach is not about that way, I want to know if there is an approach using SwiftUI modifiers like offset or position or any other SwiftUI modifier to reach the goal?
This is my current code, it does not move Window:
import SwiftUI
struct ContentView: View {
#State private var offset: CGSize = .zero
var body: some View {
VStack {
Circle()
.frame(width: 100, height: 100)
}
.frame(width: 200, height: 200)
.background(Color.purple)
.offset(offset)
.gesture(gesture)
}
var gesture: some Gesture {
return DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { dragValue in
offset = CGSize(width: dragValue.translation.width, height: -dragValue.translation.height)
}
.onEnded { _ in
offset = .zero
}
}
}

Creating a drag handle inside of a SwiftUI view (for draggable windows and such)

I'm trying to learn SwiftUI, and I had a question about how to make a component that has a handle on it which you can use to drag around. There are several tutorials online about how to make a draggable component, but none of them exactly answer the question I have, so I thought I would seek the wisdom of you fine people.
Lets say you have a view that's like a window with a title bar. For simplicity's sake, lets make it like this:
struct WindowView: View {
var body: some View {
VStack(spacing:0) {
Color.red
.frame(height:25)
Color.blue
}
}
}
I.e. the red part at the top is the title bar, and the main body of the component is the blue area. Now, this window view is contained inside another view, and you can drag it around. The way I've read it, you should do something like this (very simplified):
struct ContainerView: View {
#State private var loc = CGPoint(x:150, y:150);
var body: some View {
ZStack {
WindowView()
.frame(width:100, height:100)
.position(loc)
.gesture(DragGesture()
.onChanged { value in
loc = value.location
}
)
}
}
}
and that indeed lets you drag the component around (ignore for now that we're always dragging by the center of the image, it's not really the point):
However, this is not what I want: I don't want you to be able to drag the component around by just dragging inside the window, I only want to drag it around by dragging the red title bar. But the red title-bar is hidden somewhere inside of WindowView. I don't want to move the #State variable containing the position to inside the WindowView, it seems to me much more logical to have that inside ContainerView. But then I need to somehow forward the gesture into the embedded title bar.
I imagine the best way would be for the ContainerView to look something like this:
struct ContainerView: View {
#State private var loc = CGPoint(x:150, y:150);
var body: some View {
ZStack {
WindowView()
.frame(width:100, height:100)
.position(loc)
.titleBarGesture(DragGesture()
.onChanged { value in
loc = value.location
}
)
}
}
}
but I don't know how you would implement that .titleBarGesture in the correct way (or if this is even the proper way to do it. should the gesture be an argument to the WindowView constructor?). Can anyone help me out, give me some pointers?
Thanks in advance!
You can get smooth translation of the window using the offset from the drag, and then disable touch on the background element to prevent content from dragging.
Buttons still work in the content area.
import SwiftUI
struct WindowBar: View {
#Binding var location: CGPoint
var body: some View {
ZStack {
Color.red
.frame(height:25)
Text(String(format: "%.1f: %.1f", location.x, location.y))
}
}
}
struct WindowContent: View {
var body: some View {
ZStack {
Color.blue
.allowsHitTesting(false) // background prevents interaction
Button("Press Me") {
print("Tap")
}
}
}
}
struct WindowView: View, Identifiable {
#State var location: CGPoint // The views current center position
let id = UUID()
/// Keep track of total translation so that we don't jump on finger drag
/// SwiftUI doesn't have an onBegin callback like UIKit's gestures
#State private var startDragLocation = CGPoint.zero
#State private var isBeginDrag = true
init(location: CGPoint = .zero) {
_location = .init(initialValue: location)
}
var body: some View {
VStack(spacing:0) {
WindowBar(location: $location)
WindowContent()
}
.frame(width: 100, height: 100)
.position(location)
.gesture(DragGesture()
.onChanged({ value in
if isBeginDrag {
isBeginDrag = false
startDragLocation = location
}
// In UIKit we can reset translation to zero, but we can't in SwiftUI
// So we do book keeping to track startLocation of gesture and adjust by
// total translation
location = CGPoint(x: startDragLocation.x + value.translation.width,
y: startDragLocation.y + value.translation.height)
})
.onEnded({ value in
isBeginDrag = true /// reset for next drag
}))
}
}
struct ContainerView: View {
#State private var windows = [
WindowView(location: CGPoint(x: 50, y: 100)),
WindowView(location: CGPoint(x: 190, y: 75)),
WindowView(location: CGPoint(x: 250, y: 50))
]
var body: some View {
ZStack {
ForEach(windows) { window in
window
}
}
.frame(width: 600, height: 480)
}
}
struct ContentView: View {
var body: some View {
ContainerView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You can just use .allowsHitTesting(false) on the blue view, which will ignore the touch gesture on that view. Hence, you can only drag on the red View and still have the DragGesture outside that view.
struct WindowView: View {
var body: some View {
VStack(spacing:0) {
Color.red
.frame(height:25)
Color.blue
.allowsHitTesting(false)
}
}
}
You can wrap the DragGesture into a ViewModifier:
struct MovableByBar: ViewModifier {
static let barHeight: CGFloat = 14
#State private var loc: CGPoint!
#State private var transition: CGSize = .zero
func body(content: Content) -> some View {
if loc == nil { // Get the original position
content.padding(.top, MovableByBar.barHeight)
.overlay {
GeometryReader { geo -> Color in
DispatchQueue.main.async {
let frame = geo.frame(in: .local)
loc = CGPoint(x: frame.midX, y: frame.midY)
}
return Color.clear
}
}
} else {
VStack(spacing: 0) {
Rectangle()
.fill(.secondary)
.frame(height: MovableByBar.barHeight)
.offset(x: transition.width, y: transition.height)
.gesture (
DragGesture()
.onChanged { value in
transition = value.translation
}
.onEnded { value in
loc.x += transition.width
loc.y += transition.height
transition = .zero
}
)
content
.offset(x: transition.width,
y: transition.height)
}
.position(loc)
}
}
}
And use modifier like this:
WindowView()
.modifier(MovableByBar())
.frame(width: 80, height: 60) // `frame()` after it

How can I squeezing a Capsule while animation is active in SwiftUI

I have a Capsule which change it`s position on screen I want to make the Capsule get squeezed on both ends, but the animation does not allow me to do this, because it takes 2 View as start and end, and I just could manage left and right changing effect, so there is no room for squeezed effect to work! How can I do this as well? like apple done it.
struct CapsuleView: View {
#State private var startAnimation: Bool = Bool()
var body: some View {
return Capsule()
.fill(Color.secondary)
.frame(height: 5, alignment: .center)
.overlay(Capsule().fill(Color.green).frame(width: 100.0, height: 5, alignment: Alignment.center), alignment: startAnimation ? Alignment.trailing : Alignment.leading)
.onAppear() { startAnimation.toggle() }
.animation(Animation.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: startAnimation)
}
}
Quick and dirty code, but based on #Zaphod`s proposal try using a mask and put your capsule behind it. then animate the offset based on the frame sizes
struct SnakeLoading: View {
#State
private var animating: Bool = true
private let height: CGFloat = 10
private let capsuleWidth: CGFloat = 100
func leadingOffset(size: CGSize) -> CGFloat {
(-size.width - capsuleWidth) * 0.5 + height
}
func trailingOffset(size: CGSize) -> CGFloat {
(size.width + capsuleWidth) * 0.5 - height
}
var body: some View {
GeometryReader { geo in
ZStack {
// Background
Rectangle()
.fill(Color.gray)
// Capsule
Capsule()
.fill(Color.green)
.offset(x: animating ? leadingOffset(size: geo.size) : trailingOffset(size: geo.size))
.frame(width: capsuleWidth, height: height)
.onAppear() { animating.toggle() }
.animation(Animation.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: animating)
}
.mask(Capsule()
.frame(height: height)
)
}
.padding()
}
}

How to correctly do up an adjustable split view in SwiftUI?

This is my first time trying out SwiftUI, and I am trying to create a SwiftUI view that acts as a split view, with an adjustable handle in the center of the two views.
Here's my current code implementation example:
struct ContentView: View {
#State private var gestureTranslation = CGSize.zero
#State private var prevTranslation = CGSize.zero
var body: some View {
VStack {
Rectangle()
.fill(Color.red)
.frame(height: (UIScreen.main.bounds.height / 2) + self.gestureTranslation.height)
RoundedRectangle(cornerRadius: 5)
.frame(width: 40, height: 3)
.foregroundColor(Color.gray)
.padding(2)
.gesture(DragGesture()
.onChanged({ value in
self.gestureTranslation = CGSize(width: value.translation.width + self.prevTranslation.width, height: value.translation.height + self.prevTranslation.height)
})
.onEnded({ value in
self.gestureTranslation = CGSize(width: value.translation.width + self.prevTranslation.width, height: value.translation.height + self.prevTranslation.height)
self.prevTranslation = self.gestureTranslation
})
)
Rectangle()
.fill(Color.green)
.frame(height: (UIScreen.main.bounds.height / 2) - self.gestureTranslation.height)
}
}
}
How it looks like now:
[
This kinda works, but when dragging the handle, it is very glitchy, and that it seems to require a lot of dragging to reach a certain point.
Please advice me what went wrong. Thank you.
See How to change the height of the object by using DragGesture in SwiftUI? for a simpler solution.
My version of that:
let MIN_HEIGHT = CGFloat(50)
struct DragViewSizeView: View {
#State var height: CGFloat = MIN_HEIGHT
var body: some View {
VStack {
Rectangle()
.fill(Color.red)
.frame(width: .infinity, height: height)
HStack {
Spacer()
Rectangle()
.fill(Color.gray)
.frame(width: 100, height: 10)
.cornerRadius(10)
.gesture(
DragGesture()
.onChanged { value in
height = max(MIN_HEIGHT, height + value.translation.height)
}
)
Spacer()
}
VStack {
Text("my o my")
Spacer()
Text("hoo hah")
}
}
}
}
struct DragTestView: View {
var body: some View {
VStack {
DragViewSizeView()
Spacer() // If comment this line the result will be as on the bottom GIF example
}
}
}
struct DragTestView_Previews: PreviewProvider {
static var previews: some View {
DragTestView()
}
}
From what I have observed, the issue seems to be coming from the handle being repositioned while being dragged along. To counteract that I have set an inverse offset on the handle, so it stays in place. I have tried to cover up the persistent handle position as best as I can, by hiding it beneath the other views (zIndex).
I hope somebody else got a better solution to this question. For now, this is all that I have got:
import PlaygroundSupport
import SwiftUI
struct SplitView<PrimaryView: View, SecondaryView: View>: View {
// MARK: Props
#GestureState private var offset: CGFloat = 0
#State private var storedOffset: CGFloat = 0
let primaryView: PrimaryView
let secondaryView: SecondaryView
// MARK: Initilization
init(
#ViewBuilder top: #escaping () -> PrimaryView,
#ViewBuilder bottom: #escaping () -> SecondaryView)
{
self.primaryView = top()
self.secondaryView = bottom()
}
// MARK: Body
var body: some View {
GeometryReader { proxy in
VStack(spacing: 0) {
self.primaryView
.frame(height: (proxy.size.height / 2) + self.totalOffset)
.zIndex(1)
self.handle
.gesture(
DragGesture()
.updating(self.$offset, body: { value, state, _ in
state = value.translation.height
})
.onEnded { value in
self.storedOffset += value.translation.height
}
)
.offset(y: -self.offset)
.zIndex(0)
self.secondaryView.zIndex(1)
}
}
}
// MARK: Computed Props
var handle: some View {
RoundedRectangle(cornerRadius: 5)
.frame(width: 40, height: 3)
.foregroundColor(Color.gray)
.padding(2)
}
var totalOffset: CGFloat {
storedOffset + offset
}
}
// MARK: - Playground
let splitView = SplitView(top: {
Rectangle().foregroundColor(.red)
}, bottom: {
Rectangle().foregroundColor(.green)
})
PlaygroundPage.current.setLiveView(splitView)
Just paste the code inside XCode Playground / Swift Playgrounds
If you found a way to improve my code please let me know.

SwiftUI Navigation similar to Slack

I am trying to make a navigation UI similar to the Slack app where I have the Home Screen which Overlays the Menu Navigation screen. I created a ViewModifier which makes the Home Screen Draggable. Now I need to add functionality such that when the "Home" is tapped on the blue Menu screen, the white Home View animates back to the center. My idea was to keep track of the NavigationState in a global AppState:
enum NavigationSelection {
case menu
case home
}
final class AppState: ObservableObject {
let objectWillChange = PassthroughSubject<Void, Never>()
#Published var currentNavigationSelection: NavigationSelection = .home
}
Then when the user taps "Home", have it update the AppState's currentNavigationSelection, and have the Draggable view determine its offset based on the currentNavigationSelection. I'm really not sure about this approach and I'm having a tough time thinking about it in the new SwiftUI style. Any suggestions would be much appreciated.
The view hierarchy looks like this:
var body: some View {
ZStack {
Menu()
HomeTabView()
}
}
And the HomeTabView has a draggable ViewModifier applied:
struct Slidable: ViewModifier {
#EnvironmentObject var app: AppState
#State private var viewState = SlidableViewDragState.normal.defaultPosition
#State private var currentPosition: SlidableViewDragState = .normal {
didSet {
self.viewState = self.currentPosition.defaultPosition
}
}
func body(content: Content) -> some View {
return content
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight:
.infinity, alignment: Alignment.topLeading)
.offset(self.viewState)
.animation(.interactiveSpring())
.gesture(
DragGesture()
.onChanged({ (value) in
self.viewState = self.currentPosition.applyXTranslation(x: value.translation.width)
})
.onEnded({ (value) in
if value.translation.width < 0 && self.currentPosition == .normal {
return
}
if abs(value.translation.width) > self.currentPosition.switchThreshold {
self.currentPosition = self.currentPosition.oppositePosition
if self.currentPosition == .menuVisible {
self.app.currentNavigationSelection = .menu
}
} else {
self.viewState = self.currentPosition.defaultPosition
}
})
)
}
}
Moving both views
How do you currently define the positions of both? With my limited experience I would use a ZStack embedding HomeView and MenuView. This way you can move the views around independently.
Then you use a point variable as state, and make the DragGesture set point. Then you determine at the end of the drag what end position point is set to.
point is part of the offset-calculation. You can calculate the menu-offset with .offset(x: point.x) and the home-offset with .offset(x: -Self.maxOffset - Self.minusHomeWidth / 2 + point.x).
minusHomeWidth is the width the menu still shows when you are on the home screen.
Variables defining min and max of the point:
static let minOffset: CGFloat = 0
static let maxOffset: CGFloat = UIScreen.main.bounds.width - Self.minusHomeWidth
static let minusHomeWidth: CGFloat = UIScreen.main.bounds.width / 10
Then you can make it move to the home-view width
Button(action: {
self.point = CGPoint(x: Self.maxOffset, y: 0)
}) { Text("Go to Home") }