macosswiftuipopoverswiftui-listnspopover

SwiftUI: Show popover at mouse click location


I am working with a SwiftUI List on macOS, and I have a popover view that I need to show from each row. I have an implementation that works but it shows the popover from one fixed point on the row. I would like to be able to show the popover from where the mouse was clicked in the row, with the arrow pointing to the mouse location.

This is my current implementation:

import SwiftUI

struct TestPopoverView: View {
    let items = ["Row 1", "Row 2", "Row 3", "Row 4", "Row 5"]

    
    var body: some View {
        List(items, id: \.self) { item in
            ListRowView(item: item)
        }
    }
}

struct ListRowView: View {
    let item: String
    @State private var showPopover = false
    
    var body: some View {
        HStack {
            Text(item)
                .padding()
            Spacer()
        }
        .contentShape(Rectangle()) // Ensures the entire row is tappable
        .onTapGesture {
            showPopover = true
        }
        .popover(isPresented: $showPopover, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) {
            PopoverContentView(item: item)
        }
    }
}

struct PopoverContentView: View {
    let item: String

    var body: some View {
        VStack {
            Text("Selected: \(item)")
                .padding()
        }
        .frame(width: 200, height: 100)
    }
}


#Preview {
    TestPopoverView()
        .frame(width: 300, height: 350)
}

How do I get the mouse location and the popover showing from that precise location? I don't understand how to specify the attachmentAnchor to do this, while working inside a List.


Solution

  • Unfortunately, I don't know if there is an easy answer.
    But it's makeable and the flow is like this:

    1. PopoverAttachmentAnchor takes either a rect or a point. The type of this point is UnitPoint.

    A normalized 2D point in a view’s coordinate space.

    1. This means we need the location where the mouse clicks and the size of the view.

    2. The size we can get with GeometryReader, I use a custom modifier called readSize to hide the details from the view's body.

    3. The local coordinates of the mouse pointer we get with the onContinuousHover modifier.

    4. We store both values as they change.

    5. Finally, when the mouse clicks, we calculate the current click point in UnitPoints to use it in the popover modifier.

    Screenshot

    This is the full code:

    import SwiftUI
    
    struct TestPopoverView: View {
        let items = ["Row 1", "Row 2", "Row 3", "Row 4", "Row 5"]
        
        var body: some View {
            List(items, id: \.self) { item in
                ListRowView(item: item)
            }
        }
    }
    
    struct ListRowView: View {
        let item: String
        @State private var showPopover = false
        @State private var size: CGSize = .zero
        @State private var mouseLocation: CGPoint?
    
        func normalize(point: CGPoint?, in size: CGSize)-> UnitPoint? {
            guard let point else {
                return nil
            }
            return UnitPoint(x: point.x / size.width, y: point.y / size.height)
        }
        
        @State var clickPoint: UnitPoint = .center
        
        var body: some View {
            HStack {
                Text(item)
                    .padding()
                Spacer()
            }
            .border(Color.yellow)
            .contentShape(Rectangle()) // Ensures the entire row is tappable
            
            .readSize { size in
                self.size = size
            }
    
            .onContinuousHover { phase in
                switch phase {
                case .active(let location):
                    mouseLocation = location
                case .ended:
                    break
                }
            }
            
            .onTapGesture {
                if let point = normalize(point: mouseLocation, in: size) {
                    clickPoint = point
                    showPopover = true
                }
            }
            .popover(isPresented: $showPopover, attachmentAnchor: .point(clickPoint), arrowEdge: .bottom) {
                PopoverContentView(item: item)
            }
        }
    }
    
    extension View {
        func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
            background {
                GeometryReader { geometryProxy in
                    Color.clear
                        .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
                }
            }
            .onPreferenceChange(SizePreferenceKey.self, perform: onChange)
        }
    }
    
    private struct SizePreferenceKey: PreferenceKey {
        static var defaultValue: CGSize = .zero
        static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
    }
    

    If you are targeting macOS 13+, you could replace readSize with onGeometryChange.

            .onGeometryChange(for: CGSize.self) { proxy in
                proxy.size
            } action: {
                self.size = $0
            }