Skip to content

Instantly share code, notes, and snippets.

@Lavmint
Last active August 11, 2024 09:25
Show Gist options
  • Save Lavmint/6b98a1bfe4da1053d728136f3682a9c9 to your computer and use it in GitHub Desktop.
Save Lavmint/6b98a1bfe4da1053d728136f3682a9c9 to your computer and use it in GitHub Desktop.
Tips/Hints example
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