How to get geometry information from clicked Button?

I have read more blog posts than I care to recall and I am still unclear: is there a simple way to determine the geometry of a clicked Button?


I'm looking for the equivalent to `

Element.getBoundingClientRect()

`

in JavaScript???



I get get the width of a clicked SwiftUI button by creating a UILabel and measuring:


let labelWidth = label.intrinsicContentSize.width
return labelWidth


But even that feels hacky.


I'm looking to do something like this (pseudo code) where geometry.x gives me the x position of the Button:


GeometryReader { geometry in    
     return Button(action: { self.xPos = geometry.x}) {
          HStack {             
               Text("Sausages")
          }
     }
}



??

Accepted Reply

Aha, I see. This is a job for .anchorPreference(). This uses PreferenceKeys, which are a means of sending values back up the view stack to your ancestors. Anchor is an opaque type used to represent view geometry, and they underlie a lot of what makes GeometryReader tick.


What you do is create a PreferenceKey type. This needs a value type (it's generic), a default value, and a function to reduce multiple values down to a single one. An example might be the following, which builds a list containing anchors mapped to tab indices.


struct TabLabelBound: PreferenceKey {
    typealias Value = [(tabIdx: Int, bounds: Anchor)]
    static var defaultValue: Value = []

    func reduce(value: inout Value, next: () -> Value) {
        value.append(contentsOf: next())
    }
}


You can then use the .anchorPreference() modifier to request an anchor for some part of a view's geometry and transform it to another type, publishing it as a preference to your ancestors:


var body: some View {
    MyView()
        .anchorPreference(key: TabLabelBound.self, value: .bounds) {
            (self.myIndex, bounds: $0)     // input is Anchor, convert to TabLabelBound.Value
        }
}


Then, in the ancestor view where you've defined the labels you want to underline, you can use the .backgroundPreference() modifier to generate a background view using the value of the anchor in the preference to give it a frame:


SomeTabLabel()
    .backgroundPreference(TabLabelBound.self) { prefValue in 
        GeometryReader { geometry in
            // pull out the bounds for the active tab from the prefs
            let prefBound = prefValue.first { $0.tabIdx == self.tabIdx }

            // GeometryReader's subscript operator turns an anchor into its value type, relative to the current view bounds
            let bounds = prefBound ? geometry[prefBound!] : .zero
            
            // For the sake of simplicity, I'm using a rectangle here.
            return Rectangle().background(Color.red)
                .frame(width: bounds.size.width, height: bounds.size.height) // set bounds
                .fixedSize() // don't let it change bounds
                .offset(x: bounds.minX, y: bounds.minY) // set position
        }
    }

This is all off the cuff, but this is basically how these things are done in SwiftUI.


This was courtesy of Javier of swiftui-lab dot com. Link to a more full-featured article there following in a second (moderated) post momentarily.

Replies

SwiftUI generally doesn't work in terms of absolute coordinates. The main question here is (as it so often is): “What are you trying to do?” In other words, what do you want to do with the location of the button once you have it? It may be that there's a different way to achieve what you're looking to achieve in SwiftUI.

Hi Jim,


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: https://stackoverflow.com/questions/56505043/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`?

Aha, I see. This is a job for .anchorPreference(). This uses PreferenceKeys, which are a means of sending values back up the view stack to your ancestors. Anchor is an opaque type used to represent view geometry, and they underlie a lot of what makes GeometryReader tick.


What you do is create a PreferenceKey type. This needs a value type (it's generic), a default value, and a function to reduce multiple values down to a single one. An example might be the following, which builds a list containing anchors mapped to tab indices.


struct TabLabelBound: PreferenceKey {
    typealias Value = [(tabIdx: Int, bounds: Anchor)]
    static var defaultValue: Value = []

    func reduce(value: inout Value, next: () -> Value) {
        value.append(contentsOf: next())
    }
}


You can then use the .anchorPreference() modifier to request an anchor for some part of a view's geometry and transform it to another type, publishing it as a preference to your ancestors:


var body: some View {
    MyView()
        .anchorPreference(key: TabLabelBound.self, value: .bounds) {
            (self.myIndex, bounds: $0)     // input is Anchor, convert to TabLabelBound.Value
        }
}


Then, in the ancestor view where you've defined the labels you want to underline, you can use the .backgroundPreference() modifier to generate a background view using the value of the anchor in the preference to give it a frame:


SomeTabLabel()
    .backgroundPreference(TabLabelBound.self) { prefValue in 
        GeometryReader { geometry in
            // pull out the bounds for the active tab from the prefs
            let prefBound = prefValue.first { $0.tabIdx == self.tabIdx }

            // GeometryReader's subscript operator turns an anchor into its value type, relative to the current view bounds
            let bounds = prefBound ? geometry[prefBound!] : .zero
            
            // For the sake of simplicity, I'm using a rectangle here.
            return Rectangle().background(Color.red)
                .frame(width: bounds.size.width, height: bounds.size.height) // set bounds
                .fixedSize() // don't let it change bounds
                .offset(x: bounds.minX, y: bounds.minY) // set position
        }
    }

This is all off the cuff, but this is basically how these things are done in SwiftUI.


This was courtesy of Javier of swiftui-lab dot com. Link to a more full-featured article there following in a second (moderated) post momentarily.

Link to the original article where I learned about this: https://swiftui-lab.com/communicating-with-the-view-tree-part-2/

Thanks Jim, I'd actually looked at that before but couldn't wrap my head around it. Just got back and re-tried and got it working based on the example from Javier.


Have to say, it still seems quite unintuitive. No idea how I could ever have figured that out by myself looking through the Apple Docs! Guess that's the SwiftUI way; so much to discover!


Thanks for your help.

Yeah, the SwiftUI documentation … isn't great. In some cases there's nothing but a terse comment. In others there's nothing but a terse comment that refers the reader to the documentation of a private implementation detail. I highly recommend filing a bug report via https://feedbackassistant.apple.com/ — more reports means higher priority.