Site icon AppTractor

Соединяем две точки с помощью отрезков прямых линий и закругленных углов в 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)
        }
    }
}

Соединяем две точки с помощью отрезков прямых линий и закругленных углов в SwiftUI

Но что делать, если нам нужны прямые линии с закругленными углами на изгибах?

Две точки, один угол

Простой подход: 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 основных случаев я оставлю на ваше усмотрение, потому что логика, по сути, одна и та же!

Спасибо, что читаете!

Я чувствую, что должны быть какие-то лучшие реализации, но пока я буду придерживаться того, что описал выше!

Счастливого соединения точек!

Источник

Exit mobile version