diff --git a/Examples/Sources/PathsExample/PathsApp.swift b/Examples/Sources/PathsExample/PathsApp.swift index 754ff2bad46..e37640ef58f 100644 --- a/Examples/Sources/PathsExample/PathsApp.swift +++ b/Examples/Sources/PathsExample/PathsApp.swift @@ -64,6 +64,8 @@ struct PathsApp: App { HStack { ZStack { RoundedRectangle(cornerRadius: 12) + .inset(by: 2) + .stroke(.red, style: .init(width: 4)) .fill(.gray) HStack { diff --git a/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift b/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift index c670722737e..9cbb3c77db8 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift @@ -1,6 +1,6 @@ /// A rounded rectangle whose corner radius is equal to half the length of its /// shortest side. -public struct Capsule: Shape { +public struct Capsule: InsettableShape { /// Creates a ``Capsule`` instance. public nonisolated init() {} @@ -8,4 +8,8 @@ public struct Capsule: Shape { let radius = min(bounds.width, bounds.height) / 2.0 return RoundedRectangle(cornerRadius: radius).path(in: bounds) } + + public nonisolated func inset(by amount: Double) -> some InsettableShape { + InsettableShapeImpl(inset: amount, base: self) + } } diff --git a/Sources/SwiftCrossUI/Views/Shapes/Circle.swift b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift index c936b8f05f4..ec472c5c742 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Circle.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift @@ -2,7 +2,7 @@ /// /// Circles have equal widths and heights; the `Circle` shape will take on the /// minimum of its proposed width and height. -public struct Circle: Shape { +public struct Circle: InsettableShape { /// The ideal diameter of a ``Circle``. nonisolated static let idealDiameter = 10.0 @@ -24,4 +24,8 @@ public struct Circle: Shape { return ViewSize(diameter, diameter) } + + public nonisolated func inset(by amount: Double) -> some InsettableShape { + InsettableShapeImpl(inset: amount, base: self) + } } diff --git a/Sources/SwiftCrossUI/Views/Shapes/Ellipse.swift b/Sources/SwiftCrossUI/Views/Shapes/Ellipse.swift index 0e19176bfe6..386bafa6d8e 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Ellipse.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Ellipse.swift @@ -1,5 +1,5 @@ /// An ellipse. -public struct Ellipse: Shape { +public struct Ellipse: InsettableShape { /// Creates an ``Ellipse`` instance. public nonisolated init() {} @@ -18,4 +18,8 @@ public struct Ellipse: Shape { ) ) } + + public nonisolated func inset(by amount: Double) -> some InsettableShape { + InsettableShapeImpl(inset: amount, base: self) + } } diff --git a/Sources/SwiftCrossUI/Views/Shapes/InsettableShape.swift b/Sources/SwiftCrossUI/Views/Shapes/InsettableShape.swift new file mode 100644 index 00000000000..1c527ae4806 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/InsettableShape.swift @@ -0,0 +1,46 @@ +/// A shape type that is able to inset itself to produce another shape. +public protocol InsettableShape: Shape { + /// The type of the inset shape. + associatedtype InsetShape: InsettableShape + + /// Returns `self` inset by `amount`. + nonisolated func inset(by amount: Double) -> InsetShape +} + +/// The `InsetShape` implementation used by ``Rectangle``, ``Ellipse``, ``Circle``, and ``Capsule``. +/// +/// This implementation only works for convex shapes where insetting the shape is equivalent to +/// making the shape smaller. +struct InsettableShapeImpl: InsettableShape { + var inset: Double + var base: Base + + nonisolated func path(in bounds: Path.Rect) -> Path { + base.path( + in: .init( + x: bounds.x + inset, + y: bounds.y + inset, + width: bounds.width - 2 * inset, + height: bounds.height - 2 * inset + ) + ) + } + + nonisolated func size(fitting proposal: ProposedViewSize) -> ViewSize { + let innerProposal = ProposedViewSize( + proposal.width.map { max(0, $0 - 2 * inset) }, + proposal.height.map { max(0, $0 - 2 * inset) } + ) + + let innerSize = base.size(fitting: innerProposal) + + return ViewSize( + innerSize.width + 2 * inset, + innerSize.height + 2 * inset + ) + } + + func inset(by amount: Double) -> InsettableShapeImpl { + .init(inset: inset + amount, base: base) + } +} diff --git a/Sources/SwiftCrossUI/Views/Shapes/Rectangle.swift b/Sources/SwiftCrossUI/Views/Shapes/Rectangle.swift index fd0f96d17ec..e4a9b0ddfc9 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Rectangle.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Rectangle.swift @@ -1,9 +1,13 @@ /// A rectangle. -public struct Rectangle: Shape { +public struct Rectangle: InsettableShape { /// Creates a ``Rectangle`` instance. public nonisolated init() {} public nonisolated func path(in bounds: Path.Rect) -> Path { Path().addRectangle(bounds) } + + public nonisolated func inset(by amount: Double) -> some InsettableShape { + InsettableShapeImpl(inset: amount, base: self) + } } diff --git a/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift b/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift index be33e1e6b0f..3c5de915494 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift @@ -453,3 +453,42 @@ extension RoundedRectangle: Shape { .addLine(to: SIMD2(x: bounds.center.x, y: bounds.y)) } } + +// MARK: InsettableShape +extension RoundedRectangle: InsettableShape { + public func inset(by amount: Double) -> some InsettableShape { + InsetShapeImpl(initialCornerRadius: cornerRadius, insetAmount: amount) + } + + struct InsetShapeImpl { + var initialCornerRadius: Double + var insetAmount: Double + } +} + +extension RoundedRectangle.InsetShapeImpl: InsettableShape { + private var actualCornerRadius: Double { max(0, initialCornerRadius - insetAmount) } + + func path(in bounds: Path.Rect) -> Path { + RoundedRectangle(cornerRadius: actualCornerRadius) + .path( + in: .init( + x: bounds.x + insetAmount, + y: bounds.y + insetAmount, + width: bounds.width - 2 * insetAmount, + height: bounds.height - 2 * insetAmount + ) + ) + } + + func size(fitting proposal: ProposedViewSize) -> ViewSize { + let proposedWidth = proposal.width ?? 10 + let proposedHeight = proposal.height ?? 10 + + return ViewSize(max(proposedWidth, insetAmount * 2), max(proposedHeight, insetAmount * 2)) + } + + func inset(by amount: Double) -> Self { + Self(initialCornerRadius: initialCornerRadius, insetAmount: insetAmount + amount) + } +}