From b7a9a77f273a757700fe2348438af8a41663175f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Wa=C3=9Ferthal?= Date: Fri, 6 Feb 2026 10:45:20 +0100 Subject: [PATCH 1/2] Fix positioning calculations for right-to-left languages --- .../Playground/PopoverReaderView.swift | 8 +- .../PopoversExample/Showroom/TipView.swift | 6 +- Sources/Popover+Calculations.swift | 28 +++-- Sources/Popover+Context.swift | 6 +- Sources/Popover+Positioning.swift | 113 +++++++++--------- Sources/PopoverContainerView.swift | 26 ++-- Sources/PopoverUtilities.swift | 20 +++- 7 files changed, 121 insertions(+), 86 deletions(-) diff --git a/Examples/PopoversExample/Playground/PopoverReaderView.swift b/Examples/PopoversExample/Playground/PopoverReaderView.swift index 309cd5f..6949b86 100644 --- a/Examples/PopoversExample/Playground/PopoverReaderView.swift +++ b/Examples/PopoversExample/Playground/PopoverReaderView.swift @@ -67,12 +67,12 @@ struct PopoverReaderViewBackground: View { Circle() .fill(Color.blue, strokeBorder: Color.white, lineWidth: 3) .frame(width: 16, height: 16) - .position(context.frame.point(at: .top)) + .position(context.frame.point(at: .top, isRightToLeft: context.isRightToLeft)) .zIndex(1) Templates.CurveConnector( - start: context.frame.point(at: .top), - end: context.window.frameTagged("Frame-Tagged View").point(at: .bottom) + start: context.frame.point(at: .top, isRightToLeft: context.isRightToLeft), + end: context.window.frameTagged("Frame-Tagged View").point(at: .bottom, isRightToLeft: context.isRightToLeft) ) .stroke( Color.blue, @@ -88,7 +88,7 @@ struct PopoverReaderViewBackground: View { .fill(Color.blue, strokeBorder: Color.white, lineWidth: 3) .frame(width: 16, height: 16) .position( - context.window.frameTagged("Frame-Tagged View").point(at: .bottom) + context.window.frameTagged("Frame-Tagged View").point(at: .bottom, isRightToLeft: context.isRightToLeft) ) .zIndex(1) } diff --git a/Examples/PopoversExample/Showroom/TipView.swift b/Examples/PopoversExample/Showroom/TipView.swift index 4b79603..b6e7153 100644 --- a/Examples/PopoversExample/Showroom/TipView.swift +++ b/Examples/PopoversExample/Showroom/TipView.swift @@ -41,8 +41,8 @@ struct TipView: View { PopoverReader { context in Templates.CurveConnector( - start: context.frame.point(at: .bottom), - end: context.window.frameTagged("TipView").point(at: .top) + start: context.frame.point(at: .bottom, isRightToLeft: context.isRightToLeft), + end: context.window.frameTagged("TipView").point(at: .top, isRightToLeft: context.isRightToLeft) ) .stroke( Color(UIColor(hex: 0xFFAD46)), @@ -57,7 +57,7 @@ struct TipView: View { .fill(Color(UIColor(hex: 0xFFAD46))) .frame(width: 16, height: 16) .position( - context.window.frameTagged("TipView").point(at: .top) + context.window.frameTagged("TipView").point(at: .top, isRightToLeft: context.isRightToLeft) ) } } diff --git a/Sources/Popover+Calculations.swift b/Sources/Popover+Calculations.swift index ca71dfe..f1cfd3d 100644 --- a/Sources/Popover+Calculations.swift +++ b/Sources/Popover+Calculations.swift @@ -28,21 +28,28 @@ public extension Popover { originAnchor: originAnchor, popoverAnchor: popoverAnchor, originFrame: attributes.sourceFrame().inset(by: attributes.sourceFrameInset), - popoverSize: size ?? .zero + popoverSize: size ?? .zero, + isRightToLeft: context.isRightToLeft ) let screenEdgePadding = attributes.screenEdgePadding + let leadingPadding = context.isRightToLeft ? screenEdgePadding.right : screenEdgePadding.left + let trailingPadding = context.isRightToLeft ? screenEdgePadding.left : screenEdgePadding.right + let safeWindowFrame = window.safeAreaLayoutGuide.layoutFrame - let maxX = safeWindowFrame.maxX - screenEdgePadding.right + + let minX = safeWindowFrame.minX + leadingPadding + let maxX = safeWindowFrame.maxX - trailingPadding + let minY = safeWindowFrame.minY + screenEdgePadding.top let maxY = safeWindowFrame.maxY - screenEdgePadding.bottom /// Popover overflows on left/top side. - if popoverFrame.origin.x < screenEdgePadding.left { - popoverFrame.origin.x = screenEdgePadding.left + if popoverFrame.origin.x < minX { + popoverFrame.origin.x = minX } - if popoverFrame.origin.y < screenEdgePadding.top { - popoverFrame.origin.y = screenEdgePadding.top + if popoverFrame.origin.y < minY { + popoverFrame.origin.y = minY } /// Popover overflows on the right/bottom side. @@ -66,7 +73,8 @@ public extension Popover { let popoverFrame = attributes.position.relativeFrame( selectedAnchor: context.selectedAnchor ?? popoverAnchors.first ?? .bottom, containerFrame: attributes.sourceFrame().inset(by: attributes.sourceFrameInset), - popoverSize: size ?? .zero + popoverSize: size ?? .zero, + isRightToLeft: context.isRightToLeft ) return popoverFrame @@ -118,12 +126,14 @@ public extension Popover { popoverAnchors: popoverAnchors, containerFrame: frame, popoverSize: size, - targetPoint: point + targetPoint: point, + isRightToLeft: context.isRightToLeft ) let popoverFrame = attributes.position.relativeFrame( selectedAnchor: closestAnchor, containerFrame: frame, - popoverSize: size + popoverSize: size, + isRightToLeft: context.isRightToLeft ) context.selectedAnchor = closestAnchor diff --git a/Sources/Popover+Context.swift b/Sources/Popover+Context.swift index aa61f05..8348b9c 100644 --- a/Sources/Popover+Context.swift +++ b/Sources/Popover+Context.swift @@ -60,7 +60,11 @@ public extension Popover { return UIWindow() } } - + + public var isRightToLeft: Bool { + window.effectiveUserInterfaceLayoutDirection == .rightToLeft + } + /** The bounds of the window in which the `Popover` is being presented, or the `zero` frame if the popover has not been presented yet. */ diff --git a/Sources/Popover+Positioning.swift b/Sources/Popover+Positioning.swift index 9a25377..4f2007c 100644 --- a/Sources/Popover+Positioning.swift +++ b/Sources/Popover+Positioning.swift @@ -24,53 +24,33 @@ public extension CGRect { | | X──────────────X──────────────X bottomLeft bottom bottomRight - + - parameter at: The positional anchor of the popover. + - parameter isRightToLeft: Indicates if the device's orientation is inverted. */ - func point(at anchor: Popover.Attributes.Position.Anchor) -> CGPoint { + func point(at anchor: Popover.Attributes.Position.Anchor, isRightToLeft: Bool) -> CGPoint { + let leadingX = isRightToLeft ? origin.x + width : origin.x + let trailingX = isRightToLeft ? origin.x : origin.x + width + switch anchor { - case .topLeft: - return origin - case .top: - return CGPoint( - x: origin.x + width / 2, - y: origin.y - ) - case .topRight: - return CGPoint( - x: origin.x + width, - y: origin.y - ) - case .right: - return CGPoint( - x: origin.x + width, - y: origin.y + height / 2 - ) - case .bottomRight: - return CGPoint( - x: origin.x + width, - y: origin.y + height - ) - case .bottom: - return CGPoint( - x: origin.x + width / 2, - y: origin.y + height - ) - case .bottomLeft: - return CGPoint( - x: origin.x, - y: origin.y + height - ) - case .left: - return CGPoint( - x: origin.x, - y: origin.y + height / 2 - ) - case .center: - return CGPoint( - x: origin.x + width / 2, - y: origin.y + height / 2 - ) - } + case .topLeft: + return CGPoint(x: leadingX, y: origin.y) + case .top: + return CGPoint(x: origin.x + width / 2, y: origin.y) + case .topRight: + return CGPoint(x: trailingX, y: origin.y) + case .right: + return CGPoint(x: trailingX, y: origin.y + height / 2) + case .bottomRight: + return CGPoint(x: trailingX, y: origin.y + height) + case .bottom: + return CGPoint(x: origin.x + width / 2, y: origin.y + height) + case .bottomLeft: + return CGPoint(x: leadingX, y: origin.y + height) + case .left: + return CGPoint(x: leadingX, y: origin.y + height / 2) + case .center: + return CGPoint(x: origin.x + width / 2, y: origin.y + height / 2) + } } } @@ -81,15 +61,17 @@ public extension Popover.Attributes.Position { - parameter popoverAnchor: The anchor of the popover that attaches to `originAnchor`. - parameter originFrame: The source frame. - parameter popoverSize: The size of the popover. + - parameter isRightToLeft: Indicates if the device's orientation is inverted. */ func absoluteFrame( originAnchor: Anchor, popoverAnchor: Anchor, originFrame: CGRect, - popoverSize: CGSize + popoverSize: CGSize, + isRightToLeft: Bool ) -> CGRect { /// Get the origin point from the origin frame. - let popoverOrigin = originFrame.point(at: originAnchor) + let popoverOrigin = originFrame.point(at: originAnchor, isRightToLeft: isRightToLeft) /// Adjust `popoverOrigin` to account for `popoverAnchor.` switch popoverAnchor { @@ -146,16 +128,25 @@ public extension Popover.Attributes.Position { - parameter popoverAnchor: The popover's position within the container frame. - parameter containerFrame: The reference frame. - parameter popoverSize: The size of the popover. + - parameter isRightToLeft: Indicates if the device's orientation is inverted. */ func relativeOrigin( popoverAnchor: Anchor, containerFrame: CGRect, - popoverSize: CGSize + popoverSize: CGSize, + isRightToLeft: Bool, ) -> CGPoint { + let physicalLeftX = containerFrame.origin.x + let physicalRightX = containerFrame.origin.x + containerFrame.width - popoverSize.width + + /// Flip origin points based on layout orientation + let leadingX = isRightToLeft ? physicalRightX : physicalLeftX + let trailingX = isRightToLeft ? physicalLeftX : physicalRightX + switch popoverAnchor { case .topLeft: return CGPoint( - x: containerFrame.origin.x, + x: leadingX, y: containerFrame.origin.y ) case .top: @@ -165,17 +156,17 @@ public extension Popover.Attributes.Position { ) case .topRight: return CGPoint( - x: containerFrame.origin.x + containerFrame.width - popoverSize.width, + x: trailingX, y: containerFrame.origin.y ) case .right: return CGPoint( - x: containerFrame.origin.x + containerFrame.width - popoverSize.width, + x: trailingX, y: containerFrame.origin.y + containerFrame.height / 2 - popoverSize.height / 2 ) case .bottomRight: return CGPoint( - x: containerFrame.origin.x + containerFrame.width - popoverSize.width, + x: trailingX, y: containerFrame.origin.y + containerFrame.height - popoverSize.height ) case .bottom: @@ -185,12 +176,12 @@ public extension Popover.Attributes.Position { ) case .bottomLeft: return CGPoint( - x: containerFrame.origin.x, + x: leadingX, y: containerFrame.origin.y + containerFrame.height - popoverSize.height ) case .left: return CGPoint( - x: containerFrame.origin.x, + x: leadingX, y: containerFrame.origin.y + containerFrame.height / 2 - popoverSize.height / 2 ) case .center: @@ -207,19 +198,22 @@ public extension Popover.Attributes.Position { - parameter containerFrame: The reference frame. - parameter popoverSize: The size of the popover. - parameter targetPoint: The point to check for the closest anchor. + - parameter isRightToLeft: Indicates if the device's orientation is inverted. */ func relativeClosestAnchor( popoverAnchors: [Anchor], containerFrame: CGRect, popoverSize: CGSize, - targetPoint: CGPoint + targetPoint: CGPoint, + isRightToLeft: Bool ) -> Popover.Attributes.Position.Anchor { var (closestAnchor, closestDistance): (Popover.Attributes.Position.Anchor, CGFloat) = (.bottom, .infinity) for popoverAnchor in popoverAnchors { let origin = relativeOrigin( popoverAnchor: popoverAnchor, containerFrame: containerFrame, - popoverSize: popoverSize + popoverSize: popoverSize, + isRightToLeft: isRightToLeft ) /// Comparing distances, so no need to square the distance (saves processing power). @@ -238,16 +232,19 @@ public extension Popover.Attributes.Position { - parameter selectedAnchor: The popover's position within the container frame. - parameter containerFrame: The reference frame. - parameter popoverSize: The size of the popover. + - parameter isRightToLeft: Indicates if the device's orientation is inverted. */ func relativeFrame( selectedAnchor: Popover.Attributes.Position.Anchor, containerFrame: CGRect, - popoverSize: CGSize + popoverSize: CGSize, + isRightToLeft: Bool ) -> CGRect { let origin = relativeOrigin( popoverAnchor: selectedAnchor, containerFrame: containerFrame, - popoverSize: popoverSize + popoverSize: popoverSize, + isRightToLeft: isRightToLeft ) let frame = CGRect(origin: origin, size: popoverSize) diff --git a/Sources/PopoverContainerView.swift b/Sources/PopoverContainerView.swift index 31c208f..e4040ed 100644 --- a/Sources/PopoverContainerView.swift +++ b/Sources/PopoverContainerView.swift @@ -93,8 +93,13 @@ struct PopoverContainerView: View { .onChanged { value in func update() { + let adjustedTranslation = CGSize( + width: popover.context.isRightToLeft ? -value.translation.width : value.translation.width, + height: value.translation.height + ) + /// Apply the offset. - applyDraggingOffset(popover: popover, translation: value.translation) + applyDraggingOffset(popover: popover, translation: adjustedTranslation) /// Update the visual frame to account for the dragging offset. popover.context.frame = CGRect( @@ -119,10 +124,13 @@ struct PopoverContainerView: View { } } .onEnded { value in + let horizontalTranslation = popover.context.isRightToLeft + ? -value.predictedEndTranslation.width + : value.predictedEndTranslation.width /// The expected dragging end point. let finalOrigin = CGPoint( - x: popover.context.staticFrame.origin.x + value.predictedEndTranslation.width, + x: popover.context.staticFrame.origin.x + horizontalTranslation, y: popover.context.staticFrame.origin.y + value.predictedEndTranslation.height ) @@ -184,14 +192,16 @@ struct PopoverContainerView: View { /// Get the offset of a popover in order to place it in its correct location. func popoverOffset(for popover: Popover) -> CGSize { - guard popover.context.size != nil else { return .zero } + guard let size = popover.context.size else { return .zero } let frame = popover.context.staticFrame - let offset = CGSize( - width: frame.origin.x + ((selectedPopover == popover) ? selectedPopoverOffset.width : 0), - height: frame.origin.y + ((selectedPopover == popover) ? selectedPopoverOffset.height : 0) - ) - return offset + let adjustedHorizontalOffset: CGFloat = if popover.context.isRightToLeft { + popover.context.window.frame.width - frame.origin.x - size.width + } else { + frame.origin.x + } + + return CGSize(width: adjustedHorizontalOffset, height: frame.origin.y) } // MARK: - Dragging diff --git a/Sources/PopoverUtilities.swift b/Sources/PopoverUtilities.swift index 44a4cf8..6ae7e0a 100644 --- a/Sources/PopoverUtilities.swift +++ b/Sources/PopoverUtilities.swift @@ -103,10 +103,24 @@ public extension UIColor { /// Position a view using a rectangular frame. Access using `.frame(rect:)`. struct FrameRectModifier: ViewModifier { let rect: CGRect + + @Environment(\.layoutDirection) + private var layoutDirection + func body(content: Content) -> some View { - content - .frame(width: rect.width, height: rect.height, alignment: .topLeading) - .position(x: rect.origin.x + rect.width / 2, y: rect.origin.y + rect.height / 2) + GeometryReader { geometry in + let centerX: CGFloat = { + if layoutDirection == .rightToLeft { + return geometry.size.width - (rect.origin.x + rect.width / 2) + } else { + return rect.origin.x + rect.width / 2 + } + }() + + content + .frame(width: rect.width, height: rect.height, alignment: .topLeading) + .position(x: centerX, y: rect.origin.y + rect.height / 2) + } } } From 71000a327f52b698b410619f0727ad2f42a137c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Wa=C3=9Ferthal?= Date: Fri, 6 Feb 2026 10:45:49 +0100 Subject: [PATCH 2/2] Fix swift 6 compiler warnings --- Sources/Templates/Container.swift | 4 ++-- Sources/Templates/Shapes.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Templates/Container.swift b/Sources/Templates/Container.swift index d0a41e1..2eebb57 100644 --- a/Sources/Templates/Container.swift +++ b/Sources/Templates/Container.swift @@ -85,7 +85,7 @@ public extension Templates { X──────────────X──────────────X bottom */ - enum ArrowSide { + enum ArrowSide: Sendable { case top(ArrowAlignment) case right(ArrowAlignment) case bottom(ArrowAlignment) @@ -99,7 +99,7 @@ public extension Templates { | | * diagram is for `ArrowSide.top` */ - public enum ArrowAlignment { + public enum ArrowAlignment: Sendable { case mostCounterClockwise case centered case mostClockwise diff --git a/Sources/Templates/Shapes.swift b/Sources/Templates/Shapes.swift index a92586d..2991e83 100644 --- a/Sources/Templates/Shapes.swift +++ b/Sources/Templates/Shapes.swift @@ -172,7 +172,7 @@ public extension Templates { /** Horizontal or Vertical line. */ - public enum Direction { + public enum Direction: Sendable { case horizontal case vertical }