Last active
June 18, 2024 17:23
-
-
Save ryanlintott/5e065dde4838848f2f6e95323fe26ada to your computer and use it in GitHub Desktop.
AnimatablePair is limited to only two values. You can nest AnimatablePair to add more values but theres a lot of repetition and using .first.second.first etc… when accessing the values. Now that we have access to Swift 6 with parameter pack iteration we could have an AnimatablePack. Similar to how view builders are no longer limited to 10 views …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// AnimatablePack.swift | |
// ShapeUp | |
// | |
// Created by Ryan Lintott on 2023-08-02. | |
// | |
/// AnimatablePack uses parameter pack iteration that is only available in swift 6.0 | |
/// https://forums.swift.org/t/pitch-enable-pack-iteration/66168 | |
#if compiler(>=6.0) | |
import SwiftUI | |
/** | |
A parameter pack implementation of `AnimatablePair` | |
Conforming to Animatable with AnimatablePair: | |
```swift | |
struct MyShape: Animatable { | |
var animatableData: AnimatablePair<CGFloat, AnimatablePair<RelatableValue, Double>> { | |
get { AnimatablePair(insetAmount, AnimatablePair(cornerRadius, rotation)) } | |
set { | |
insetAmount = newValue.first | |
cornerRadius = newValue.second.first | |
rotation = newValue.second.second | |
} | |
} | |
} | |
``` | |
Conforming to Animatable with AnimatablePack: | |
```swift | |
struct MyShape: Animatable { | |
var animatableData: AnimatablePack<CGFloat, RelatableValue, Double> { | |
get { AnimatablePack(insetAmount, cornerRadius, rotation) } | |
set { (insetAmount, cornerRadius, rotation) = newValue() } | |
} | |
} | |
``` | |
*/ | |
@available(iOS 17, macOS 14, watchOS 10, tvOS 17, *) | |
@dynamicMemberLookup | |
public struct AnimatablePack<each Item: VectorArithmetic>: VectorArithmetic { | |
/// Pack of items that conform to `VectorArithmetic` | |
public var item: (repeat each Item) | |
/// Creates an `Animatable` pack of items | |
/// - Parameter item: Pack of items that conform to `VectorArithmetic` | |
public init(_ item: repeat each Item) { | |
self.item = (repeat each item) | |
} | |
/// Access elements in the same was as a tuple using pack.1, pack.2, etc... | |
public subscript<V>(dynamicMember keyPath: WritableKeyPath<(repeat each Item), V>) -> V { | |
get { item[keyPath: keyPath] } | |
set { item[keyPath: keyPath] = newValue } | |
} | |
public func callAsFunction() -> (repeat each Item) { | |
item | |
} | |
} | |
@available(iOS 17, macOS 14, watchOS 10, tvOS 17, *) | |
extension AnimatablePack: Sendable where repeat each Item: Sendable { } | |
@available(iOS 17, macOS 14, watchOS 10, tvOS 17, *) | |
public extension AnimatablePack { | |
static var zero: Self { | |
.init(repeat (each Item).zero) | |
} | |
static func + (lhs: Self, rhs: Self) -> Self { | |
.init(repeat (each lhs.item) + (each rhs.item)) | |
} | |
static func - (lhs: Self, rhs: Self) -> Self { | |
.init(repeat (each lhs.item) - (each rhs.item)) | |
} | |
mutating func scale(by rhs: Double) { | |
item = (repeat (each item).scaled(by: rhs)) | |
} | |
static func == (lhs: Self, rhs: Self) -> Bool { | |
(lhs - rhs).magnitudeSquared == .zero | |
} | |
var magnitudeSquared: Double { | |
var value = 0.0 | |
for item in repeat each item { | |
value += item.magnitudeSquared | |
} | |
return value | |
} | |
} | |
#endif |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// AnimatablePackExampleView.swift | |
// AnimatablePackExample | |
// | |
// Created by Ryan Lintott on 2024-06-17. | |
// | |
import SwiftUI | |
struct AnimatablePackExampleView: View { | |
@State private var pointHeight: CGFloat = 100 | |
@State private var topTaper: CGFloat = 0 | |
@State private var bottomTaper: CGFloat = 50 | |
var body: some View { | |
VStack { | |
Text("Both pentagons are animatable by all three properties at the same time but one was much easier to write.") | |
VStack { | |
Pentagon1(pointHeight: pointHeight, topTaper: topTaper, bottomTaper: bottomTaper) | |
.fill(.red) | |
Pentagon2(pointHeight: pointHeight, topTaper: topTaper, bottomTaper: bottomTaper) | |
.fill(.blue) | |
} | |
.animation(.default.speed(0.3), value: pointHeight) | |
.animation(.default.speed(0.3), value: topTaper) | |
.animation(.default.speed(0.3), value: bottomTaper) | |
Stepper("Point Height \(pointHeight.formatted())", value: $pointHeight, in: 0...200, step: 20) | |
Stepper("Top Taper \(topTaper.formatted())", value: $topTaper, in: 0...200, step: 20) | |
Stepper("Bottom Taper \(bottomTaper.formatted())", value: $bottomTaper, in: 0...200, step: 20) | |
} | |
.padding() | |
} | |
} | |
struct Pentagon1: Shape { | |
var pointHeight: CGFloat | |
var topTaper: CGFloat | |
var bottomTaper: CGFloat | |
/// Notice how the AnimatablePair becomes difficult to write with more than two arguments and likely extremely difficult with many. | |
var animatableData: AnimatablePair<CGFloat, AnimatablePair<CGFloat, CGFloat>> { | |
get { | |
.init(pointHeight, .init(topTaper, bottomTaper)) | |
} | |
set { | |
pointHeight = newValue.first | |
topTaper = newValue.second.first | |
bottomTaper = newValue.second.second | |
} | |
} | |
public init(pointHeight: CGFloat, topTaper: CGFloat, bottomTaper: CGFloat) { | |
self.pointHeight = pointHeight | |
self.topTaper = topTaper | |
self.bottomTaper = bottomTaper | |
} | |
public nonisolated func path(in rect: CGRect) -> Path { | |
var path = Path() | |
path.addLines([ | |
CGPoint(x: rect.minX + bottomTaper, y: rect.maxY), | |
CGPoint(x: rect.minX + topTaper, y: rect.minY + pointHeight), | |
CGPoint(x: rect.midX, y: rect.minY), | |
CGPoint(x: rect.maxX - topTaper, y: rect.minY + pointHeight), | |
CGPoint(x: rect.maxX - bottomTaper, y: rect.maxY) | |
]) | |
return path | |
} | |
} | |
struct Pentagon2: Shape { | |
var pointHeight: CGFloat | |
var topTaper: CGFloat | |
var bottomTaper: CGFloat | |
/// With AnimatablePack you only need to remember the order of the arguements in order to list them. | |
var animatableData: AnimatablePack<CGFloat, CGFloat, CGFloat> { | |
get { | |
.init(pointHeight, topTaper, bottomTaper) | |
} | |
set { | |
(pointHeight, topTaper, bottomTaper) = newValue() | |
} | |
} | |
public init(pointHeight: CGFloat, topTaper: CGFloat, bottomTaper: CGFloat) { | |
self.pointHeight = pointHeight | |
self.topTaper = topTaper | |
self.bottomTaper = bottomTaper | |
} | |
public nonisolated func path(in rect: CGRect) -> Path { | |
var path = Path() | |
path.addLines([ | |
CGPoint(x: rect.minX + bottomTaper, y: rect.maxY), | |
CGPoint(x: rect.minX + topTaper, y: rect.minY + pointHeight), | |
CGPoint(x: rect.midX, y: rect.minY), | |
CGPoint(x: rect.maxX - topTaper, y: rect.minY + pointHeight), | |
CGPoint(x: rect.maxX - bottomTaper, y: rect.maxY) | |
]) | |
return path | |
} | |
} | |
#Preview { | |
AnimatablePackExampleView() | |
} |
That's a great idea, I will! Thanks!
Nice, thanks.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Very cool! Suggestion: add a
@dynamicMemberLookup
subscript. Like this:With this you can access the pack elements like on a normal tuple: