Разработка
Соединяем две точки с помощью отрезков прямых линий и закругленных углов в SwiftUI
Соединить две точки очень просто — достаточно провести между ними одну или несколько линий.
Соединить две точки очень просто — достаточно провести между ними одну или несколько линий.
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:)
, чтобы извлечь закругленный угол + две прямые линии.
Давайте используем простой пример, в котором у нас будет две точки и один угол, чтобы проверить это!
xxxxxxxxxx
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
.
Зачем нам это нужно, если мы все равно его обрезаем? Давайте проверим, что мы получим, если этого не сделать.
xxxxxxxxxx
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
? Если оно у нас есть, нам даже не нужно вычислять позицию.
xxxxxxxxxx
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
.
xxxxxxxxxx
trimmed
.stroke(style: .init(lineWidth: 4, lineCap: .round, lineJoin: .round))
.zIndex(10)
.background(.yellow.opacity(0.2))
И если мы попытаемся установить для нее рамку, она, очевидно, уже не будет находиться в правильном положении.
xxxxxxxxxx
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
, как показано ниже.
xxxxxxxxxx
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
.
xxxxxxxxxx
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
.
Давайте сравним разницу на примере упрощенной версии.
xxxxxxxxxx
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
.
xxxxxxxxxx
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
сверху.
xxxxxxxxxx
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 основных случаев я оставлю на ваше усмотрение, потому что логика, по сути, одна и та же!
Спасибо, что читаете!
Я чувствую, что должны быть какие-то лучшие реализации, но пока я буду придерживаться того, что описал выше!
Счастливого соединения точек!
-
Программирование4 недели назад
Конец программирования в том виде, в котором мы его знаем
-
Видео и подкасты для разработчиков1 неделя назад
Как устроена мобильная архитектура. Интервью с тех. лидером юнита «Mobile Architecture» из AvitoTech
-
Магазины приложений3 недели назад
Магазин игр Aptoide запустился на iOS в Европе
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.8