SwiftUI Navigation similar to Slack - swift

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") }

Related

SwiftUI gestures in the toolbar area ignored

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)
}
}
}
}

SwiftUI - detecting Long Press while keeping TabView swipable

I'm trying to detect a Long Press gesture on TabView that's swipable.
The issue is that it disables TabView's swipable behavior at the moment.
Applying the gesture on individual VStacks didn't work either - the long press doesn't get detected if I tap on the background.
Here's a simplified version of my code - it can be copy-pasted into Swift Playground:
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State var currentSlideIndex: Int = 0
#GestureState var isPaused: Bool = false
var body: some View {
let tap = LongPressGesture(minimumDuration: 0.5,
maximumDistance: 10)
.updating($isPaused) { value, state, transaction in
state = value
}
Text(isPaused ? "Paused" : "Not Paused")
TabView(selection: $currentSlideIndex) {
VStack {
Text("Slide 1")
Button(action: { print("Slide 1 Button Tapped")}, label: {
Text("Button 1")
})
}
VStack {
Text("Slide 2")
Button(action: { print("Slide 2 Button Tapped")}, label: {
Text("Button 2")
})
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(width: 400, height: 700, alignment: .bottom)
.simultaneousGesture(tap)
.onChange(of: isPaused, perform: { value in
print("isPaused: \(isPaused)")
})
}
}
PlaygroundPage.current.setLiveView(ContentView())
The overall idea is that this TabView will be rotating the slides automatically but holding a finger on any of them should pause the rotation (similar to Instagram stories). I removed that logic for simplicity.
Update: using DragGesture didn't work either.
The issue here is with the precedence of Animations in SwiftUI. Because TabView is a struct we are unable to change, its animation detection precedence cannot really be changed. The solution to this, however clunky, is to write our own custom tab view that has the expected behavior.
I apologize for how much code is here, but the behavior you described is surprisingly complex. In essence, we have a TimeLineView that is sending automatic updates to our view, telling it to change the pages, as you would see on Instagram. TimeLineView is a new feature, so if you want this to work old school, you could replace it with a Timer and its onReceive method, but I'm using this for brevity.
In the pages themselves, we are listening for this update, but only actually changing the page to the next one if there is room to do so and we are not long pressing the view. We use the .updating modifier on the LongPressGesture to know exactly when our finger is still on the screen or not. This LongPressGesture is combined in a SimultaneousGesture with a DragGesture, so that the drag can also be activated. In the drag gesture, we wait for the user's mouse/finger to traverse a certain percentage of the screen before animating the change in pages.
When sliding backwards, we initiate an async request to set the animation direction back to sliding forwards once the animation completes, so that updates received from the TimeLineView still animate in the correct direction, no matter which way we just swiped. Using custom gestures here has the added benefit that if you choose to do so, you can implement some fancy geometry effects to more closely emulate Instagram's animations. At the same time, our CustomPageView is still fully interactable, which means I can still click on button1 and see it's onTapGesture print message!
One caveat of passing in Views to a struct as a generic as I am doing in CustomTabView is that all of the views must be of the same type, which is part of the reason the pages are now reusable structs in their own right. If you have any questions about what you can / can't do with this methodology, let me know, but I've just run this in Playground same as you and it works exactly as described.
import SwiftUI
import PlaygroundSupport
// Custom Tab View to handle all the expected behaviors
struct CustomTabView<Page: View>: View {
#Binding var pageIndex: Int
var pages: [Page]
/// Primary initializer for a Custom Tab View
/// - Parameters:
/// - pageIndex: The index controlling which page we are viewing
/// - pages: The views to display on each Page
init(_ pageIndex: Binding<Int>, pages: [() -> Page]) {
self._pageIndex = pageIndex
self.pages = pages.map { $0() }
}
struct currentPage<Page: View>: View {
#Binding var pageIndex: Int
#GestureState private var isPressingDown: Bool = false
#State private var forwards: Bool = true
private let animationDuration = 0.5
var pages: [Page]
var date: Date
/// - Parameters:
/// - pageIndex: The index controlling which page we are viewing
/// - pages: The views to display on each Page
/// - date: The current date
init(_ pageIndex: Binding<Int>, pages: [Page], date: Date) {
self._pageIndex = pageIndex
self.pages = pages
self.date = date
}
var body: some View {
// Ensure that the Page fills the screen
GeometryReader { bounds in
ZStack {
// You can obviously change this to whatever you like, but it's here right now because SwiftUI will not look for gestures on a clear background, and the CustomPageView I implemented is extremely bare
Color.red
// Space the Page horizontally to keep it centered
HStack {
Spacer()
pages[pageIndex]
Spacer()
}
}
// Frame this ZStack with the GeometryReader's bounds to include the full width in gesturable bounds
.frame(width: bounds.size.width, height: bounds.size.height)
// Identify this page by its index so SwiftUI knows our views are not identical
.id("page\(pageIndex)")
// Specify the transition type
.transition(getTransition())
.gesture(
// Either of these Gestures are allowed
SimultaneousGesture(
// Case 1, we perform a Long Press
LongPressGesture(minimumDuration: 0.1, maximumDistance: .infinity)
// Sequence this Gesture before an infinitely long press that will never trigger
.sequenced(before: LongPressGesture(minimumDuration: .infinity))
// Update the isPressingDown value
.updating($isPressingDown) { value, state, _ in
switch value {
// This means the first Gesture completed
case .second(true, nil):
// Update the GestureState
state = true
// We don't need to handle any other case
default: break
}
},
// Case 2, we perform a Drag Gesture
DragGesture(minimumDistance: 10)
.onChanged { onDragChange($0, bounds.size) }
)
)
}
// If the user releases their finger, set the slide animation direction back to forwards
.onChange(of: isPressingDown) { newValue in
if !newValue { forwards = true }
}
// When we receive a signal from the TimeLineView
.onChange(of: date) { _ in
// If the animation is not pause and there are still pages left to show
if !isPressingDown && pageIndex < pages.count - 1{
// This should always say sliding forwards, because this will only be triggered automatically
print("changing pages by sliding \(forwards ? "forwards" : "backwards")")
// Animate the change in pages
withAnimation(.easeIn(duration: animationDuration)) {
pageIndex += 1
}
}
}
}
/// Called when the Drag Gesture occurs
private func onDragChange(_ drag: DragGesture.Value, _ frame: CGSize) {
// If we've dragged across at least 15% of the screen, change the Page Index
if abs(drag.translation.width) / frame.width > 0.15 {
// If we're moving forwards and there is room
if drag.translation.width < 0 && pageIndex < pages.count - 1 {
forwards = true
withAnimation(.easeInOut(duration: animationDuration)) {
pageIndex += 1
}
}
// If we're moving backwards and there is room
else if drag.translation.width > 0 && pageIndex > 0 {
forwards = false
withAnimation(.easeInOut(duration: animationDuration)) {
pageIndex -= 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
forwards = true
}
}
}
}
// Tell the view which direction to slide
private func getTransition() -> AnyTransition {
// If we are swiping left / moving forwards
if forwards {
return .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
}
// If we are swiping right / moving backwards
else {
return .asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
}
}
}
var body: some View {
ZStack {
// Create a TimeLine that updates every five seconds automatically
TimelineView(.periodic(from: Date(), by: 5)) { timeLine in
// Create a current page struct, as we cant react to timeLine.date changes in this view
currentPage($pageIndex, pages: pages, date: timeLine.date)
}
}
}
}
// This is the view that becomes the Page in our Custom Tab View, you can make it whatever you want as long as it is reusable
struct CustomPageView: View {
var title: String
var buttonTitle: String
var buttonAction: () -> ()
var body: some View {
VStack {
Text("\(title)")
Button(action: { buttonAction() }, label: { Text("\(buttonTitle)") })
}
}
}
struct ContentView: View {
#State var currentSlideIndex: Int = 0
#GestureState var isPaused: Bool = false
var body: some View {
CustomTabView($currentSlideIndex, pages: [
{
CustomPageView(title: "slide 1", buttonTitle: "button 1", buttonAction: { print("slide 1 button tapped") })
},
{
CustomPageView(title: "slide 2", buttonTitle: "button 2", buttonAction: { print("slide 2 button tapped") })
}]
)
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(width: 400, height: 700, alignment: .bottom)
}
}
PlaygroundPage.current.setLiveView(ContentView())
I found the best and cleanest solution to this is just to add a clear view on top of your tabView when the slide show is active and put the gesture recognizer on that.
I haven't shown the implementation of the start stop timer which depends on your design.
private let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
#State var slideshowPlaying = false
#State var selection = 0
var body: some View {
ZStack {
TabView(selection: $selection) {
ForEach(modelArray.indices, id: \.self) { index in
SomeView()
.tag(index)
}
}
.tabViewStyle(PageTabViewStyle())
.background(Color(.systemGroupedBackground))
.onReceive(self.timer) { _ in
if selection < modelArray.count + 1 {
selection += 1
} else {
selection = 0
}
}
if slideshowPlaying {
Color.clear
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0).onChanged { _ in
slideshowPlaying = false
})
}
}
}

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 to enable multiple gestures?

I have created an app that consists of the main view inside of which there are multiple views, one per shape. For the shape, I would like to enable some interaction in order to transform its properties upon tapping. As the transformation depends on the tap location as well, I have implemented it as DragGesture the following way:
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { gesture in
print("Tap location: \(gesture.startLocation)")
})
Besides the transformation of individual shapes, I wanted to enable the user to drag the whole content and/or resize it. Thus, in the main view, I have implemented a drag gesture and magnification gesture. As tapping and dragging are conflicting, I added an option to toggle the interaction mode between tapping and dragging. The transformation is enabled/disabled by checking the following condition inside shape view:
.allowsHitTesting(dragGestureMode == DragGestureEnum.TransformShape)
Nevertheless, sometimes when I try to resize the image, the gesture is interpreted as a drag gesture on a certain shape that performs the logic for tapping (dragging with minimum distance 0) on that shape instead of resizing (in case of dragging mode this is not a problem).
Below is the logic of the main view and the shape view which instances are laid inside main view:
struct MainView: View {
#EnvironmentObject var mainViewModel : MainViewModel
#State private var offset = CGSize.zero
#State private var draggedSize: CGSize = CGSize.zero
#State private var scale: CGFloat = 1.0
#State private var scaledSize: CGFloat = 1.0
var body: some View {
GeometryReader {
geometry in
ZStack {
ForEach(mainViewModel.shapeItemKeys, id: \.self){ id in
let shapeItem = $mainViewModel.shapeItemsByKey[id]
ShapeView(shapeItem: shapeItem, dragGestureMode: $mainViewModel.dragGestureMode)
}
}
.frame(width: min(geometry.size.width, geometry.size.height), height: min(geometry.size.width, geometry.size.height), alignment: .center)
.contentShape(Rectangle())
.offset(x: self.draggedSize.width, y: self.draggedSize.height)
.scaleEffect(self.scaledSize)
.gesture(
DragGesture()
.onChanged { gesture in
self.draggedSize = gesture.translation
self.draggedSize.width += self.offset.width
self.draggedSize.height += self.offset.height
}
.onEnded { _ in
self.offset = self.draggedSize
}
)
.gesture(MagnificationGesture()
.onChanged({ (scale) in
self.scaledSize = scale.magnitude
self.scaledSize *= self.scale
})
.onEnded({ (scaleFinal) in
self.scale = scaleFinal
print("New scale: \(self.scale)")
self.scale = self.scaledSize
}))
}
}
}
struct ShapeView: View {
#Binding var shapeItem: ShapeItem?
#Binding var dragGestureMode: DragGestureEnum
var layersToRemove: [Int] = []
init(shapeItem: Binding<ShapeItem?>, dragGestureMode: Binding<DragGestureEnum>) {
self._shapeItem = shapeItem
self._dragGestureMode = dragGestureMode
}
var body: some View {
ZStack {
shapeItem!.path
.foregroundColor(Color(shapeItem!.color))
.overlay(
ZStack {
// ... some logic
}
, alignment: .leading)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { gesture in
print("Tap location: \(gesture.startLocation)")
}
)
.allowsHitTesting(dragGestureMode == DragGestureEnum.TransformShape)
}
}
}
Does anyone know what is a good way to enable combinations of gestures ((dragging or resizing) or (tapping or resizing)) in this case (i.e. tap should be detected on shape view, drag or resize should be detected on the main view and tap on the individual shape and drag on the main view are exclusive) in order to get the expected user experience (prevent resizing gesture from being interpreted as tapping or dragging)?
You can use simultaneous gesture modifier, like
.simultaneousGesture(MagnificationGesture()
...

SwiftUI - how to get coordinate/position of clicked Button

Short version: How do I get the coordinates of a clicked Button in SwiftUI?
I'm looking for something like this (pseudo code) where geometry.x is the position of the clicked button in the current view:
GeometryReader { geometry in
return Button(action: { self.xPos = geometry.x}) {
HStack {
Text("Sausages")
}
}
}
Long version: I'm beginning SwiftUI and Swift so wondering how best to achieve this conceptually.
To give the concrete example I am playing with:
Imagine a tab system where I want to move an underline indicator to the position of a clicked button.
[aside]
There is a answer in this post that visually does what I am going for but it seems rather complicated: How to make view the size of another view in SwiftUI
[/aside]
Here is my outer struct which builds the tab bar and the rectangle (the current indicator) I am trying to size and position:
import SwiftUI
import UIKit
struct TabBar: View {
var tabs:ITabGroup
#State private var selected = "Popular"
#State private var indicatorX: CGFloat = 0
#State private var indicatorWidth: CGFloat = 10
#State private var selectedIndex: Int = 0
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 0) {
ForEach(tabs.tabs) { tab in
EachTab(tab: tab, choice: self.$selected, tabs: self.tabs, x: self.$indicatorX, wid: self.$indicatorWidth)
}
}.frame(minWidth: 0, maxWidth: .infinity, minHeight: 40, maxHeight: 40).padding(.leading, 10)
.background(Color(UIColor(hex: "#333333")!))
Rectangle()
.frame(width: indicatorWidth, height: 3 )
.foregroundColor(Color(UIColor(hex: "#1fcf9a")!))
.animation(Animation.spring())
}.frame(height: 43, alignment: .leading)
}
}
Here is my struct that creates each tab item and includes a nested func to get the width of the clicked item:
struct EachTab: View {
// INCOMING!
var tab: ITab
#Binding var choice: String
var tabs: ITabGroup
#Binding var x: CGFloat
#Binding var wid: CGFloat
#State private var labelWidth: CGRect = CGRect()
private func tabWidth(labelText: String, size: CGFloat) -> CGFloat {
let label = UILabel()
label.text = labelText
label.font = label.font.withSize(size)
let labelWidth = label.intrinsicContentSize.width
return labelWidth
}
var body: some View {
Button(action: { self.choice = self.tab.title; self.x = HERE; self.wid = self.tabWidth(labelText: self.choice, size: 13)}) {
HStack {
// Determine Tab colour based on whether selected or default within black or green rab set
if self.choice == self.tab.title {
Text(self.tab.title).foregroundColor(Color(UIColor(hex: "#FFFFFF")!)).font(.system(size: 13)).padding(.trailing, 10).animation(nil)
} else {
Text(self.tab.title).foregroundColor(Color(UIColor(hex: "#dddddd")!)).font(.system(size: 13)).padding(.trailing, 10).animation(nil)
}
}
}
// TODO: remove default transition fade on button click
}
}
Creating a non SwiftUI UILabel to get the width of the Button seems a bit wonky. Is there a better way?
Is there a simple way to get the coordinates of the clicked SwiftUI Button?
You can use a DragGesture recogniser with a minimum drag distance of 0, which provides you the location info. However, if you combine the DragGesture with your button, the drag gesture won't be triggered on normal clicks of the button. It will only be triggered when the drag ends outside of the button.
You can get rid of the button completely, but of course then you lose the default button styling.
The view would look like this in that case:
struct MyView: View {
#State var xPos: CGFloat = 0
var body: some View {
GeometryReader { geometry in
HStack {
Text("Sausages: \(self.xPos)")
}
}.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global).onEnded { dragGesture in
self.xPos = dragGesture.location.x
})
}
}
The coordinateSpace parameter specifies if you want the touch position in .local or .global space. In the local space, the position is relative to the view that you've attached the gesture to. For example, if I had a Text view in the middle of the screen, my local y position would be almost 0, whereas my global y would be half of the screen height.
This tripped me up a bit, but this example shows the idea:
struct MyView2: View {
#State var localY: CGFloat = 0
#State var globalY: CGFloat = 0
var body: some View {
VStack {
Text("local y: \(self.localY)")
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local).onEnded { dragGesture in
self.localY = dragGesture.location.y
})
Text("global y: \(self.globalY)")
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global).onEnded { dragGesture in
self.globalY = dragGesture.location.y
})
}
}
}
struct ContentView: View {
var body: some View {
VStack {
Button(action: { print("Button pressed")}) { Text("Button") }
}.simultaneousGesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onEnded { print("Changed \($0.location)") })
}
}
This solution seems to work, add a simultaneous gesture on Button, unfortunatelly it does not work if the Button is places in a Form
Turns out I solved this problem by adapting the example on https://swiftui-lab.com/communicating-with-the-view-tree-part-2/
The essence of the technique is using anchorPreference which is a means of sending data about one view back up the chain to ancestral views. I couldn't find any docs on this in the Apple world but I can attest that it works.
I'm not adding code here as the reference link also includes explanation that I don't feel qualified to re-iterate here!