Last active
August 11, 2024 09:25
-
-
Save Lavmint/6b98a1bfe4da1053d728136f3682a9c9 to your computer and use it in GitHub Desktop.
Tips/Hints example
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import Combine | |
struct ContentView: View { | |
struct _State { | |
var source: CGRect? | |
var content: CGRect? | |
var isArrow: Bool { | |
return source != nil | |
} | |
} | |
@State var state = _State() | |
var body: some View { | |
ZStack { | |
Text("Text") | |
.padding(24) | |
.background(Color.red) | |
.background( | |
GeometryReader( | |
content: { (child: GeometryProxy) in | |
Path { (path) in | |
OperationQueue.main.addOperation { | |
self.state.source = child.frame(in: .global) | |
} | |
} | |
.fill(Color.red) | |
} | |
) | |
) | |
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) | |
.offset(x: 0, y: -170) | |
if state.isArrow { | |
TipView(from: state.source!, contentSize: state.content?.size) { | |
Text("very very long very very long very very long very very long very very long very very long very very long very very long very very long very very long very very long very very long very very long very very long very very long very very long very very long very very long very very long") | |
.padding(24) | |
.background( | |
GeometryReader { (geo: GeometryProxy) in | |
Path { (path) in | |
OperationQueue.main.addOperation { | |
self.state.content = geo.frame(in: .global) | |
} | |
} | |
.fill(Color.red) | |
} | |
) | |
} | |
} | |
} | |
.background(Color.yellow) | |
.edgesIgnoringSafeArea(.all) | |
} | |
} | |
struct TipView<Content: View>: View { | |
private let source: CGRect | |
private var content: Content | |
private let arrowAltitudeVertexRadius: CGFloat | |
private let arrowAltitude: CGFloat | |
private let backgroundColor: Color | |
private let conentSize: CGSize? | |
init(from source: CGRect, contentSize: CGSize?, arrowRadius: CGFloat? = nil, arrowAltitude: CGFloat? = nil, backgroundColor: Color? = nil, _ builder: () -> Content) { | |
self.source = source | |
self.arrowAltitudeVertexRadius = arrowRadius ?? 2 | |
self.arrowAltitude = arrowAltitude ?? 28 | |
self.backgroundColor = backgroundColor ?? Color.white | |
self.content = builder() | |
self.conentSize = contentSize | |
} | |
var body: some View { | |
GeometryReader { (geo: GeometryProxy) in | |
self._content(in: geo.frame(in: .global)) | |
} | |
} | |
func _content(in rect: CGRect) -> some View { | |
let arrowBaseEdge = _arrowBaseEdge(in: rect) | |
let arrowAltitudeVertex = _arrowAltitudeVertex(to: arrowBaseEdge) | |
let tipAreaBounds = _tipAreaBounds(in: rect, start: arrowAltitudeVertex, direction: arrowBaseEdge) | |
let tip = _tip(content, in: rect, arrowAltitudeVertex: arrowAltitudeVertex, arrowBase: arrowBaseEdge) | |
return ZStack { | |
Arrow(start: arrowAltitudeVertex, direction: arrowBaseEdge, radius: arrowAltitudeVertexRadius, height: self.arrowAltitude) | |
.fill(backgroundColor) | |
ZStack { | |
tip | |
} | |
.frame(width: tipAreaBounds.width, height: tipAreaBounds.height) | |
.position(CGPoint(x: tipAreaBounds.midX, y: tipAreaBounds.midY)) | |
} | |
} | |
private func _tipAreaBounds(in parent: CGRect, start: CGPoint, direction: Edge) -> CGRect { | |
let yTop, yBottom, xLeft, xRight: CGFloat | |
switch direction { | |
case .top: | |
yTop = parent.origin.y | |
yBottom = start.y - arrowAltitude | |
xLeft = parent.origin.x | |
xRight = parent.origin.x + parent.width | |
case .bottom: | |
yTop = start.y + arrowAltitude | |
yBottom = parent.origin.y + parent.height | |
xLeft = parent.origin.x | |
xRight = parent.origin.x + parent.width | |
case .leading: | |
yTop = parent.origin.y | |
yBottom = parent.origin.y + parent.height | |
xLeft = parent.origin.x | |
xRight = start.x - arrowAltitude | |
case .trailing: | |
yTop = parent.origin.y | |
yBottom = parent.origin.y + parent.height | |
xLeft = start.x + arrowAltitude | |
xRight = parent.origin.x + parent.width | |
} | |
return CGRect(x: xLeft, y: yTop, width: xRight - xLeft, height: yBottom - yTop) | |
} | |
private func _arrowAltitudeVertex(to direction: Edge) -> CGPoint { | |
switch direction { | |
case .top: | |
return CGPoint(x: source.midX, y: source.origin.y) | |
case .bottom: | |
return CGPoint(x: source.midX, y: source.origin.y + source.height) | |
case .leading: | |
return CGPoint(x: source.origin.x, y: source.midY) | |
case .trailing: | |
return CGPoint(x: source.origin.x + source.width, y: source.midY) | |
} | |
} | |
private func _arrowBaseEdge(in parent: CGRect) -> Edge { | |
let yTop = parent.origin.y | |
let yBottom = parent.origin.y + parent.height | |
let xLeft = parent.origin.x | |
let xRight = parent.origin.x + parent.width | |
let topDistance = abs(yTop - source.midY) | |
let bottomDistance = abs(yBottom - source.midY) | |
let leftDistance = abs(xLeft - source.midX) | |
let rightDistance = abs(xRight - source.midX) | |
let isCenterX = abs(leftDistance - rightDistance) <= 1 | |
let isCenterY = abs(topDistance - bottomDistance) <= 1 | |
let distance: [Edge: CGFloat] = [ | |
.top: topDistance, | |
.bottom: bottomDistance, | |
.leading: leftDistance, | |
.trailing: rightDistance | |
] | |
let maxDistance = distance.max { (l, r) -> Bool in | |
return l.value < r.value | |
}! | |
let tipEdge: Edge | |
if UIDevice.current.orientation.isLandscape && isCenterX && rightDistance > maxDistance.value { | |
tipEdge = .trailing | |
} else if isCenterY && topDistance > maxDistance.value { | |
tipEdge = .top | |
} else { | |
tipEdge = maxDistance.key | |
} | |
return tipEdge | |
} | |
private func _tip(_ content: Content, in rect: CGRect, arrowAltitudeVertex vertex: CGPoint, arrowBase: Edge) -> some View { | |
let alignment: Alignment | |
let edges: Edge.Set | |
let padding: CGFloat = 20 | |
switch arrowBase { | |
case .top: | |
alignment = .bottom | |
edges = [.leading, .trailing, .top] | |
case .bottom: | |
alignment = .top | |
edges = [.leading, .trailing, .bottom] | |
case .leading: | |
alignment = .trailing | |
edges = [.trailing, .bottom, .top] | |
case .trailing: | |
alignment = .leading | |
edges = [.bottom, .trailing, .top] | |
} | |
//check x axis bounds | |
var xAnchor = vertex.x | |
if var width = conentSize?.width { | |
width = width + padding * 2 | |
let inBoundLeft = vertex.x - rect.origin.x < width / 2 | |
let inBoundRight = rect.origin.x + rect.width - vertex.x < width / 2 | |
if !inBoundRight { | |
xAnchor = rect.origin.x + width / 2 | |
} | |
if !inBoundLeft { | |
xAnchor = rect.origin.x + rect.width - width / 2 | |
} | |
} | |
//check y axis bounds | |
var yAnchor = vertex.y | |
if var height = conentSize?.height { | |
height = height + padding * 2 | |
let inBoundTop = vertex.y - rect.origin.y >= height / 2 | |
let inBoundBottom = rect.origin.y + rect.height - vertex.y >= height / 2 | |
if !inBoundBottom { | |
yAnchor = rect.origin.y + height / 2 | |
} | |
if !inBoundTop { | |
yAnchor = rect.origin.y + rect.height - height / 2 | |
} | |
} | |
let view = content | |
.background(backgroundColor) | |
.cornerRadius(16) | |
.padding(edges, padding) | |
switch arrowBase { | |
case .top, .bottom: | |
return view | |
.frame(maxHeight: .infinity, alignment: alignment) | |
.offset(x: xAnchor - rect.midX) | |
case .leading, .trailing: | |
return view | |
.frame(maxWidth: .infinity, alignment: alignment) | |
.offset(y: yAnchor - rect.midY) | |
} | |
} | |
} | |
struct Arrow: Shape { | |
let altitudeVertex: CGPoint | |
let baseEdge: Edge | |
let altitudeVertexRadius: CGFloat | |
let altitude: CGFloat | |
init(start: CGPoint, direction: Edge, radius: CGFloat = 2, height: CGFloat = 28) { | |
self.altitudeVertex = start | |
self.baseEdge = direction | |
self.altitudeVertexRadius = radius | |
self.altitude = height | |
} | |
func path(in rect: CGRect) -> Path { | |
let path = Path { (path) in | |
switch baseEdge { | |
case .top: | |
let p1 = CGPoint(x: altitudeVertex.x + altitudeVertexRadius, y: altitudeVertex.y - altitudeVertexRadius) | |
path.move(to: p1) | |
path.addLine(to: CGPoint(x: altitudeVertex.x + altitude / 2, y: altitudeVertex.y - altitude)) | |
path.addLine(to: CGPoint(x: altitudeVertex.x - altitude / 2, y: altitudeVertex.y - altitude)) | |
path.addLine(to: CGPoint(x: altitudeVertex.x - altitudeVertexRadius, y: altitudeVertex.y - altitudeVertexRadius)) | |
path.addQuadCurve( | |
to: p1, | |
control: CGPoint(x: altitudeVertex.x, y: altitudeVertex.y + altitudeVertexRadius) | |
) | |
case .trailing: | |
let p1 = CGPoint(x: altitudeVertex.x + altitudeVertexRadius, y: altitudeVertex.y + altitudeVertexRadius) | |
path.move(to: p1) | |
path.addLine(to: CGPoint(x: altitudeVertex.x + altitude, y: altitudeVertex.y + altitude / 2)) | |
path.addLine(to: CGPoint(x: altitudeVertex.x + altitude, y: altitudeVertex.y - altitude / 2)) | |
path.addLine(to: CGPoint(x: altitudeVertex.x + altitudeVertexRadius, y: altitudeVertex.y - altitudeVertexRadius)) | |
path.addQuadCurve( | |
to: p1, | |
control: CGPoint(x: altitudeVertex.x - altitudeVertexRadius, y: altitudeVertex.y) | |
) | |
case .bottom: | |
let p1 = CGPoint(x: altitudeVertex.x + altitudeVertexRadius, y: altitudeVertex.y + altitudeVertexRadius) | |
path.move(to: p1) | |
path.addLine(to: CGPoint(x: altitudeVertex.x + altitude / 2, y: altitudeVertex.y + altitude)) | |
path.addLine(to: CGPoint(x: altitudeVertex.x - altitude / 2, y: altitudeVertex.y + altitude)) | |
path.addLine(to: CGPoint(x: altitudeVertex.x - altitudeVertexRadius, y: altitudeVertex.y + altitudeVertexRadius)) | |
path.addQuadCurve( | |
to: p1, | |
control: CGPoint(x: altitudeVertex.x, y: altitudeVertex.y - altitudeVertexRadius) | |
) | |
case .leading: | |
let p1 = CGPoint(x: altitudeVertex.x - altitudeVertexRadius, y: altitudeVertex.y - altitudeVertexRadius) | |
path.move(to: p1) | |
path.addLine(to: CGPoint(x: altitudeVertex.x - altitude, y: altitudeVertex.y - altitude / 2)) | |
path.addLine(to: CGPoint(x: altitudeVertex.x - altitude, y: altitudeVertex.y + altitude / 2)) | |
path.addLine(to: CGPoint(x: altitudeVertex.x - altitudeVertexRadius, y: altitudeVertex.y + altitudeVertexRadius)) | |
path.addQuadCurve( | |
to: p1, | |
control: CGPoint(x: altitudeVertex.x + altitudeVertexRadius, y: altitudeVertex.y) | |
) | |
} | |
} | |
return path | |
} | |
} | |
extension View { | |
func eraseToAnyView() -> AnyView { | |
return AnyView(self) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment