Соединить две точки очень просто — достаточно провести между ними одну или несколько линий.
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 основных случаев я оставлю на ваше усмотрение, потому что логика, по сути, одна и та же!
Спасибо, что читаете!
Я чувствую, что должны быть какие-то лучшие реализации, но пока я буду придерживаться того, что описал выше!
Счастливого соединения точек!