Соединить две точки очень просто — достаточно провести между ними одну или несколько линий.
struct CurvedArrowDemo: View {
var body: some View {
let start: CGPoint = CGPoint(x: 100, y: 100)
let end: CGPoint = CGPoint(x: 300, y: 300)
ZStack {
Circle()
.fill(.blue)
.frame(width: 16)
.position(start)
Path { path in
path.move(to: start)
path.addLine(to: end)
}
.stroke(.gray, lineWidth: 4)
Path { path in
path.move(to: start)
path.addQuadCurve(to: end, control: CGPoint(x: 100, y: 300))
}
.stroke(.gray, lineWidth: 4)
Path { path in
path.move(to: start)
path.addCurve(to: end, control1: CGPoint(x: 100, y: 200), control2: CGPoint(x: 300, y: 200))
}
.stroke(.gray, lineWidth: 4)
Path { path in
path.move(to: start)
path.addLine(to: CGPoint(x: 100, y: 300))
path.addLine(to: end)
}
.stroke(.gray, lineWidth: 4)
Circle()
.fill(.red)
.frame(width: 16)
.position(end)
}
}
}
Но что делать, если нам нужны прямые линии с закругленными углами на изгибах?
Две точки, один угол
Простой подход: UIBezierPath + trimmedPath
UIBezierPath имеет инициализатор init(roundedRect:byRoundingCorners:cornerRadii:), который позволяет нам создать новый объект кривой Безье с прямоугольной траекторией, скругленной по указанным углам.
(Каждый раз, когда я использую что-то из UI, я начинаю задаваться вопросом, наступит ли день, когда SwiftUI сможет догнать UIKit…)
Затем мы объединим его с trimmedPath(from:to:), чтобы извлечь закругленный угол + две прямые линии.
Давайте используем простой пример, в котором у нас будет две точки и один угол, чтобы проверить это!
ZStack {
let start: CGPoint = CGPoint(x: 100, y: 100)
let end: CGPoint = CGPoint(x: 300, y: 300)
Circle()
.fill(.blue)
.frame(width: 16)
.position(start)
let width = abs(start.x - end.x)
let height = abs(start.y - end.y)
let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height))
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: [.topRight, .bottomLeft], cornerRadii: CGSize(width: 8, height: 8))
let trimmed = Path(path.cgPath).trimmedPath(from: 0, to: 0.5)
trimmed
.stroke(style: .init(lineWidth: 4, lineCap: .round, lineJoin: .round))
.frame(width: width, height: height)
.zIndex(10)
.scaleEffect(x: end.x > start.x ? 1 : -1, anchor: .leading)
.scaleEffect(y: end.y > start.y ? 1 : -1, anchor: .top)
.position(x: start.x + width/2, y: start.y + height/2)
Circle()
.fill(.red)
.frame(width: 16)
.position(end)
}
Выглядит просто! Правда?
Все действительно очень просто, за исключением пары моментов, на которые следует обратить внимание!
Скругление левого нижнего угла
Когда мы создаем наш UIBezierPath, в дополнение к правому верхнему углу topRight, который мы фактически сохраняем, мы также просим его скруглить левый нижний угол bottomLeft.
Зачем нам это нужно, если мы все равно его обрезаем? Давайте проверим, что мы получим, если этого не сделать.
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: [.topRight], cornerRadii: CGSize(width: 8, height: 8)) let trimmed = Path(path.cgPath).trimmedPath(from: 0, to: 0.5)
Почему так происходит?
Когда угол скругляется, длина пути уменьшается, и 0.5 уже не обязательно является целевой точкой.
Поэтому, чтобы сохранить симметрию, мы также скруглим левый нижний угол.
Почему мы не можем просто уменьшить 0.5 до какого-нибудь 0.49? Значение будет зависеть от радиуса угла, и, очевидно, мы не хотим пытаться вычислять его каждый раз!
Прямоугольник с нулевым началом
Для прямоугольника, определяющего базовую форму UIBezierPath, мы указали origin в zero и добавили модификатор position к окончательно обрезанному пути.
Почему мы не можем просто использовать start в качестве origin? Если оно у нас есть, нам даже не нужно вычислять позицию.
let width = abs(start.x - end.x)
let height = abs(start.y - end.y)
let rect = CGRect(origin: start, size: CGSize(width: width, height: height))
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: [.topRight, .bottomLeft], cornerRadii: CGSize(width: 8, height: 8))
let trimmed = Path(path.cgPath).trimmedPath(from: 0, to: 0.5)
trimmed
.stroke(style: .init(lineWidth: 4, lineCap: .round, lineJoin: .round))
.zIndex(10)
Выглядят похоже?
С этим есть несколько проблем.
Во-первых, если вы работали с Path и Shape, вы можете знать, что они будут пытаться занять любое свободное место, если мы не установим frame.
trimmed .stroke(style: .init(lineWidth: 4, lineCap: .round, lineJoin: .round)) .zIndex(10) .background(.yellow.opacity(0.2))
И если мы попытаемся установить для нее рамку, она, очевидно, уже не будет находиться в правильном положении.
trimmed
.stroke(style: .init(lineWidth: 4, lineCap: .round, lineJoin: .round))
.zIndex(10)
.background(.yellow.opacity(0.2))
.frame(width: width, height: height)
Также, если мы хотим соединить точку с углом не справа вверху, а слева внизу (в случае, когда start находится слева вверху относительно end), при нашем первоначальном подходе с началом в нуле, мы можем просто применить rotationEffect, как показано ниже.
let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height))
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: [.topRight, .bottomLeft], cornerRadii: CGSize(width: 8, height: 8))
let trimmed = Path(path.cgPath).trimmedPath(from: 0, to: 0.5)
trimmed
.stroke(style: .init(lineWidth: 4, lineCap: .round, lineJoin: .round))
.frame(width: width, height: height)
.zIndex(10)
.rotationEffect(.degrees(180))
.scaleEffect(x: end.x > start.x ? 1 : -1, anchor: .leading)
.scaleEffect(y: end.y > start.y ? 1 : -1, anchor: .top)
.position(x: start.x + width/2, y: start.y + height/2)
Однако это не сработает, если мы назначили origin в start.
let rect = CGRect(origin: start, size: CGSize(width: width, height: height))
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: [.topRight, .bottomLeft], cornerRadii: CGSize(width: 8, height: 8))
let trimmed = Path(path.cgPath).trimmedPath(from: 0, to: 0.5)
trimmed
.stroke(style: .init(lineWidth: 4, lineCap: .round, lineJoin: .round))
.rotation(.degrees(180))
.zIndex(10)
ScaleEffect на Frame
Кроме того, вместо того чтобы применять scale на trimmed пути, я применяю scaleEffect после frame.
Давайте сравним разницу на примере упрощенной версии.
trimmed
.stroke(style: .init(lineWidth: 4, lineCap: .round, lineJoin: .round))
.frame(width: width, height: height)
.zIndex(10)
.background(.yellow.opacity(0.2))
.scaleEffect(x: -1, y: 1, anchor: .leading)
.position(x: start.x + width/2, y: start.y + height/2)
trimmed
.stroke(style: .init(lineWidth: 4, lineCap: .round, lineJoin: .round))
.scale(x: -1, y: 1, anchor: .leading)
.frame(width: width, height: height)
.zIndex(10)
.background(.red.opacity(0.2))
.position(x: start.x + width/2, y: start.y + height/2)
Как видите, сама линия окажется в нужном месте, а frame — нет. На всякий случай, если есть окружающее содержимое, я думаю, что лучше сделать так, как мы сделали.
Больше сегментов линий, больше углов
Если мы посмотрим на другие сервисы для построения графиков или блок-схем, то количество сегментов линий и углов обычно зависит от направления входа и выхода из начальной и конечной точки, при этом мы стараемся не заходить за точку.
Разумеется, существует бесконечное число возможных маршрутов. Кроме того, нам нужно будет произвольно задать некоторые ограничения, например, изгиб прямо посередине или в сторону одного из концов, длину отрезка линии, если мы движемся не в направлении цели из-за направления входа/выхода, и т.д.
Сколько возможностей нам нужно учесть?
Мы можем войти (или выйти) в точку в 4 разных направлениях, значит ли это, что у нас 4 * 4 = 16 случаев?
НЕТ! НЕТ! НЕТ!
Внутри каждого случая у нас также есть 9 подслучаев (почти, некоторые случаи имеют одну и ту же реализацию), описывающих относительное положение между начальной точкой и конечной! Это одни и те же точки? Имеют ли они одинаковые x или y? Или нет?
Давайте рассмотрим один из основных случаев в качестве примера, а остальное вы допишите сами!
Предположим, мы хотим, чтобы наша линия выходила из начальной точки с правой стороны и входила в конечную точку сверху! Давайте посмотрим, как мы можем справиться с каждым из этих подслучаев.
Для удобства использования я вывожу это в другом View.
struct RoundedCornerPath: View {
enum PointDirection {
case top
case bottom
case left
case right
}
var start: CGPoint
var startDirection: PointDirection
var end: CGPoint
var endDirection: PointDirection
var radius: CGFloat = 8
var outPathLength: CGFloat = 32
private enum RelativePosition {
case none
case top
case bottom
case left
case right
case topLeft
case topRight
case bottomLeft
case bottomRight
}
private var relativePosition: RelativePosition {
if start == end {
return .none
}
if start.x == end.x {
return start.y < end.y ? .top : .bottom
}
if start.y == end.y {
return start.x < end.x ? .left : .right
}
switch (start.x < end.x, start.y < end.y) {
case (true, true):
return .topLeft
case (false, true):
return .topRight
case (true, false):
return .bottomLeft
case (false, false):
return .bottomRight
}
}
var body: some View {
roundedCornerPath()
}
private func roundedCornerPath() -> some View {
switch (startDirection, endDirection) {
case (.top, .top):
break
case (.top, .right):
break
case (.top, .bottom):
break
case (.top, .left):
break
case (.right, .top):
switch relativePosition {
case .none:
let endPoint1 = CGPoint(x: start.x + outPathLength, y: start.y - outPathLength/2 )
let subPath1 = subPath(start: start, end: endPoint1)
let endPoint2 = CGPoint(x: start.x + outPathLength/2, y: start.y - outPathLength )
let subPath2 = subPath(start: endPoint1, end: endPoint2, rotation: .degrees(180))
let subPath3 = subPath(start: endPoint2, end: end)
return AnyView(ZStack {
subPath1
subPath2
subPath3
})
case .top, .topRight:
let diffY = end.y - start.y
let endPoint1 = CGPoint(x: start.x + outPathLength, y: start.y + diffY/4 )
let subPath1 = subPath(start: start, end: endPoint1)
let endPoint2 = CGPoint(x: start.x + outPathLength/2, y: start.y + diffY/2 )
let subPath2 = subPath(start: endPoint1, end: endPoint2, rotation: .degrees(180))
let subPath3 = subPath(start: endPoint2, end: end)
return AnyView(ZStack {
subPath1
subPath2
subPath3
})
case .bottom:
let endPoint1 = CGPoint(x: start.x + outPathLength, y: end.y)
let subPath1 = subPath(start: start, end: endPoint1)
let endPoint2 = CGPoint(x: start.x + outPathLength/2, y: end.y - outPathLength)
let subPath2 = subPath(start: endPoint1, end: endPoint2, rotation: .degrees(180))
let subPath3 = subPath(start: endPoint2, end: end)
return AnyView(ZStack {
subPath1
subPath2
subPath3
})
case .left, .bottomLeft:
let diffX = end.x - start.x
let endPoint1 = CGPoint(x: start.x + diffX/2, y: end.y - outPathLength/2)
let subPath1 = subPath(start: start, end: endPoint1)
let endPoint2 = CGPoint(x: start.x + diffX*3/4, y: end.y - outPathLength)
let subPath2 = subPath(start: endPoint1, end: endPoint2, rotation: .degrees(180))
let subPath3 = subPath(start: endPoint2, end: end)
return AnyView(ZStack {
subPath1
subPath2
subPath3
})
case .right, .bottomRight:
let diffX = end.x - start.x
let endPoint1 = CGPoint(x: start.x + outPathLength, y: end.y - outPathLength/2)
let subPath1 = subPath(start: start, end: endPoint1)
let endPoint2 = CGPoint(x: end.x - diffX, y: end.y - outPathLength)
let subPath2 = subPath(start: endPoint1, end: endPoint2, rotation: .degrees(180))
let subPath3 = subPath(start: endPoint2, end: end)
return AnyView(ZStack {
subPath1
subPath2
subPath3
})
case .topLeft:
return AnyView(subPath(start: start, end: end))
}
case (.right, .right):
break
case (.right, .bottom):
break
case (.right, .left):
break
case (.bottom, .top):
break
case (.bottom, .right):
break
case (.bottom, .bottom):
break
case (.bottom, .left):
break
case (.left, .top):
break
case (.left, .right):
break
case (.left, .bottom):
break
case (.left, .left):
break
}
return AnyView(Text("to be implemented"))
}
private func subPath(start: CGPoint, end: CGPoint, rotation: Angle = .zero) -> some View {
let width = abs(start.x - end.x)
let height = abs(start.y - end.y)
let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height))
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: [.topRight, .bottomLeft], cornerRadii: CGSize(width: radius, height: radius))
let trimmed = Path(path.cgPath).trimmedPath(from: 0, to: 0.5)
return trimmed
.stroke(style: .init(lineWidth: 4, lineCap: .round, lineJoin: .round))
.rotation(rotation)
.frame(width: width, height: height)
.zIndex(10)
.scaleEffect(x: end.x > start.x ? 1 : -1, anchor: .leading)
.scaleEffect(y: end.y > start.y ? 1 : -1, anchor: .top)
.position(x: start.x + width/2, y: start.y + height/2)
}
}
Обратите внимание, что даже если start пересекается с end, то есть relativePosition это none, все равно будут некоторые отрезки линий и углы, которые мы должны нарисовать в зависимости от направлений входа и выхода.
Вот простой вид для визуализации 9 подслучаев основного случая: выход из start справа, вход в end сверху.
struct CurvedArrowDemo: View {
var body: some View {
ZStack {
// none
makePath(start: .init(x: 200, y: 150), end: .init(x: 200, y: 150))
// top left
makePath(start: .init(x: 50, y: 100), end: .init(x: 150, y: 150))
// top
makePath(start: .init(x: 300, y: 250), end: .init(x: 300, y: 350))
// bottom
makePath(start: .init(x: 100, y: 350), end: .init(x: 100, y: 250))
// left
makePath(start: .init(x: 150, y: 400), end: .init(x: 250, y: 400))
// right
makePath(start: .init(x: 150, y: 500), end: .init(x: 50, y: 500))
// top right
makePath(start: .init(x: 150, y: 600), end: .init(x: 50, y: 650))
// bottom left
makePath(start: .init(x: 200, y: 550), end: .init(x: 350, y: 500))
// bottom right
makePath(start: .init(x: 350, y: 700), end: .init(x: 250, y: 650))
}
}
func makePath(start: CGPoint, end: CGPoint) -> some View {
return ZStack {
Circle()
.fill(.blue)
.frame(width: 16)
.position(start)
.overlay(content: {
Text("start")
.position(start)
.offset(y: 16)
})
RoundedCornerPath(start: start, startDirection: .right, end: end, endDirection: .top)
Circle()
.fill(.red)
.frame(width: 16)
.position(end)
}
}
}
Конечно, вы можете разделить отрезки прямых разными способами, в разных позициях, например, по направлению к end!
Остальные 15 основных случаев я оставлю на ваше усмотрение, потому что логика, по сути, одна и та же!
Спасибо, что читаете!
Я чувствую, что должны быть какие-то лучшие реализации, но пока я буду придерживаться того, что описал выше!
Счастливого соединения точек!
