mac 的 SwiftUI 形状的悬停效果

Hover effect for SwiftUI Shapes for the mac

我想为 mac 的 SwiftUI 形状创建一个悬停效果,它考虑了实际形状。 onHover 修饰符不好,因为它在鼠标进入框架时触发,而不是 SwiftUI ShapehoverEffect 不适用于 Mac。

如何创建考虑底层形状的悬停效果?

import SwiftUI

struct HoverViewModifier : ViewModifier {
    @State private var hovered = false
    func body(content: Content) -> some View {
        content
            .foregroundColor(hovered ? .accentColor : .primary)
            .onHover { isHovered in
                self.hovered = isHovered
            }
    }
}

struct MyStar : Shape {
    func path(in rect : CGRect) -> Path {
        let points = [
            (50, 0), (61, 33), (97, 34), (69, 56), (79, 90), (50, 70), (20, 90), (30, 56), ( 2, 34), (38, 33)
        ].map { (x : CGFloat, y : CGFloat) in
            CGPoint(x: x * rect.width / 100, y: y * rect.height / 100)
        }
        var path = Path()
        path.addLines(points)
        return path
    }
}

struct ContentView : View {
    var body: some View {
        HStack {
            Circle().modifier(HoverViewModifier())
            MyStar().modifier(HoverViewModifier())
        }.frame(width: 200, height: 100)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

这篇优秀的文章 https://swiftui-lab.com/a-powerful-combo/ 演示了如何跟踪鼠标移动。

为此,作者使用 NSViewRepresentableNSHostingView 来访问 AppKit 的 NSView.

我们可以 return 一个布尔值来指示鼠标是否在 Shapepath 内,而不是 return 鼠标位置。

由于我们的内容是 Shape 而不是 View,因此需要相应地调整 where 子句:where Content : Shape.

该解决方案使用 NSTrackingArea.mouseMoved 选项。添加 .mouseEnteredAndExited 选项是有意义的,以确保当鼠标离开视图时恢复 non-hovered 状态。

要将 Shape 一方面连接到 TrackingAreaView,另一方面连接到 HoverViewModifier,可以创建 Shape 的扩展。此外,HooverViewModifier 中的 @State 必须替换为 @ObservedObject 才能从 Shape 访问它。相应的 ObservableObject 可以简单地看起来像这样:

class HoverModel: ObservableObject {
    @Published var hovered: Bool = false
}

扩展可能如下所示:

extension Shape {
    func hover(modifier: HoverViewModifier) -> some View {
        return self.mouse { inside in
            modifier.hoverModel.hovered = inside
        }
        .modifier(modifier)
    }
    
    func mouse(insideShape: @escaping (Bool) -> Void) -> some View {
        TrackingAreaView(insideShape: insideShape) { self }
    }
}

因此,调用必须稍作更改:

Circle().hover(modifier: HoverViewModifier())
MyStar().hover(modifier: HoverViewModifier())

一个完整的例子与问题中的改编代码结合开头提到的文章中的稍微扩展的例子可能看起来像这样:

import SwiftUI

class HoverModel: ObservableObject {
    @Published var hovered: Bool = false
}

struct HoverViewModifier : ViewModifier {
    @ObservedObject var hoverModel = HoverModel()
    func body(content: Content) -> some View {
        content
            .foregroundColor(hoverModel.hovered ? .accentColor : .primary)
    }
}

extension Shape {
    func hover(modifier: HoverViewModifier) -> some View {
        return self.mouse { inside in
            modifier.hoverModel.hovered = inside
        }
        .modifier(modifier)
    }
    
    func mouse(insideShape: @escaping (Bool) -> Void) -> some View {
        TrackingAreaView(insideShape: insideShape) { self }
    }
}

struct MyStar : Shape {
    func path(in rect : CGRect) -> Path {
        let points = [
            (50, 0), (61, 33), (97, 34), (69, 56), (79, 90), (50, 70), (20, 90), (30, 56), ( 2, 34), (38, 33)
        ].map { (x : CGFloat, y : CGFloat) in
            CGPoint(x: x * rect.width / 100, y: y * rect.height / 100)
        }
        var path = Path()
        path.addLines(points)
        return path
    }
    
}

struct ContentView : View {
    var body: some View {
        HStack {
            Circle().hover(modifier: HoverViewModifier())
            MyStar().hover(modifier: HoverViewModifier())
        }.frame(width: 200, height: 100)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

struct TrackingAreaView<Content>: View where Content : Shape {
    let insideShape: (Bool) -> Void
    let content: () -> Content
    
    init(insideShape: @escaping (Bool) -> Void, @ViewBuilder content: @escaping () -> Content) {
        self.insideShape = insideShape
        self.content = content
    }
    
    var body: some View {
        TrackingAreaRepresentable(insideShape: insideShape, content: self.content())
    }
}

struct TrackingAreaRepresentable<Content>: NSViewRepresentable where Content: Shape {
    let insideShape: (Bool) -> Void
    let content: Content
    
    func makeNSView(context: Context) -> NSHostingView<Content> {
        return TrackingNSHostingView(insideShape: insideShape, rootView: self.content)
    }
    
    func updateNSView(_ nsView: NSHostingView<Content>, context: Context) {
    }
}

class TrackingNSHostingView<Content>: NSHostingView<Content> where Content : Shape {
    let insideShape: (Bool) -> Void
    var path = Path()
    
    init(insideShape: @escaping (Bool) -> Void, rootView: Content) {
        self.insideShape = insideShape
        super.init(rootView: rootView)
        setupTrackingArea()
    }
    
    override func layout() {
        super.layout()
        self.path = rootView.path(in: self.bounds)
    }
    
    required init(rootView: Content) {
        fatalError("init(rootView:) has not been implemented")
    }
    
    @objc required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func setupTrackingArea() {
        let options: NSTrackingArea.Options = [.mouseMoved, .mouseEnteredAndExited, .activeAlways, .inVisibleRect]
        self.addTrackingArea(NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil))
    }
        
    override func mouseExited(with event: NSEvent) {
        self.insideShape(false)
    }
    
    override func mouseMoved(with event: NSEvent) {
        return self.checkInside(with: event)
    }
    
    override func mouseEntered(with event: NSEvent) {
        return self.checkInside(with: event)
    }
    
    private func checkInside(with event: NSEvent) {
        let inside = path.contains(self.convert(event.locationInWindow, from: nil))
        self.insideShape(inside)
    }
}

演示

我能够从@stephan-schlecht的回答中去掉 ObservableObject 并制作一个类似于 built-in onHoveronHoverInside 修饰符像这样:

extension Shape {
    func onHoverInside(action: @escaping (Bool) -> Void) -> some View {
        TrackingAreaView(insideShape: action) { self }
    }
}

struct MyHoveredShape<Content> : View where Content : Shape {
    @State private var hovered : Bool = false
    let shape : Content
    
    var body: some View {
        shape
            .onHoverInside { isHoveredInside in
                hovered = isHoveredInside
            }
            .foregroundColor(hovered ? .accentColor : .primary)
    }
}

extension Shape {
    func myHoverModifier() -> some View {
        return MyHoveredShape(shape: self)
    }
}

struct ContentView : View {
    var body: some View {
        HStack {
            Circle().myHoverModifier()
            MyStar().myHoverModifier()
        }.frame(width: 200, height: 100)
    }
}

接下来就看他的回答了