Positioning Views in ForEach SwiftUI - swiftui

I would like to add animating views to a parent view. I know that the parent view needs to position the children but I'm having trouble coming up with the formula to implement. I have the first couple of views right but once I get to 4 and up its a problem! I would like the views to appear in a grid with 3 columns.
Here is some reproducible code ready to be copy and pasted.
import SwiftUI
struct CustomView: View, Identifiable {
#State private var startAnimation = false
let id = UUID()
var body: some View {
Circle()
.frame(width: 50, height: 50)
.scaleEffect(x: startAnimation ? 2 : 1,
y: startAnimation ? 2 : 1)
.animation(Animation.interpolatingSpring(mass: 2, stiffness: 20, damping: 1, initialVelocity: 1))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.startAnimation = true
}
}
}
}
struct StartView: View {
#State private var userSelection: [CustomView] = []
var body: some View {
VStack(spacing: -20) {
Button("Add View") {
self.userSelection.append(CustomView())
}
LazyVGrid(columns: gridStyle) {
ForEach(Array(userSelection.enumerated()), id: \.0 ){ index, equip in
CustomView()
.position(x: widthBasedOn(index: index), y: heightBasedOn(index: index))
}
.padding([])
}
.frame(width: UIScreen.main.bounds.width * 0.5,
height: UIScreen.main.bounds.height * 0.8)
}
}
let gridStyle = [
GridItem(.flexible(minimum: 0, maximum: 100), spacing: -50),
GridItem(.flexible(minimum: 0, maximum: 100), spacing: -50),
GridItem(.flexible(minimum: 0, maximum: 100), spacing: -50)
]
private func widthBasedOn(index: Int) -> CGFloat {
if index % 3 != 0 {
if index > 3 {
let difference = index - 4
return CGFloat(index * difference * 100)
}
let answer = CGFloat(index * 100)
print("\(index) width should be: \(answer)")
return answer
}
return 0
}
private func heightBasedOn(index: Int) -> CGFloat {
if index > 3 && index < 6 {
return 100
}
return 200
}
}
struct EquipmentSelectionView_Previews: PreviewProvider {
static var previews: some View {
StartView()
}
}

Since most of your question is somewhat vague, and I am not sure about the specifics, this is my solution. Feel free to respond, and I will be glad to answer your question further with more tailored solution.
I removed many of your code that was unnecessary or overly-complicated. For example, I removed the widthBasedOn and heightBasedOn methods. I also changed the array property var userSelection: [CustomView] to var numberOfViews = 0.
Note: Both your original code and my solution cause all the circles to wiggle up and down, whenever a new circle is added.
I suggest that you copy paste this code snippet, run it in Xcode, and see if this is what you want.
struct CustomView: View, Identifiable {
#State private var startAnimation = false
let id = UUID()
var body: some View {
Circle()
//Changing the frame size of the circle, making it bigger or smaller
.frame(width: startAnimation ? 100 : 50, height: startAnimation ? 100 : 50)
.animation(Animation.interpolatingSpring(mass: 2, stiffness: 20, damping: 1, initialVelocity: 1))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.startAnimation = true
}
}
}
}
struct StartView: View {
//View will display this number of circles
#State private var numberOfViews = 0
var body: some View {
VStack() {
Button("Add View") {
self.numberOfViews += 1
}
.padding(.top, 100)
Spacer()
LazyVGrid(columns: gridStyle) {
//Add a new circle CustomView() to the LazyVGrid for each number of views
ForEach(0..<numberOfViews, id: \.self ){view in
CustomView()
}
}
}
}
//3 columns, flexible spacing for elments. In this case, equal amount of spacing.
let gridStyle = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
]
}
struct EquipmentSelectionView_Previews: PreviewProvider {
static var previews: some View {
StartView()
}
}
Limiting number of circles
To limit the number of circles:
if numberOfViews < 9 {
self.numberOfViews += 1
}
Positioning the button
To position the button, you can add padding:
Button("Add View") {
if numberOfViews < 9 {
self.numberOfViews += 1
}
}
.padding(.top, 100)
Overlap vs. No Overlap
Using there .frame modifier will not have any overlap:
.frame(width: startAnimation ? 100 : 50, height: startAnimation ? 100 : 50)
But if you do want overlap, use .scaleEffect:
.scaleEffect(x: startAnimation ? 2 : 1,
y: startAnimation ? 2 : 1)
P.S. Unfortunately, I can't show you the results with GIF images because Stackoverflow keep giving me upload errors.

Related

How to animate a view in a circular motion using its real-time position coordinates?

I'm currently working on a SwiftUI project, and in order to detect intersections/collisions, I need real-time coordinates, which SwiftUI animations cannot offer. After doing some research, I came across a wonderful question by Kike regarding how to get the real-time coordinates of a view when it is moving/transitioning. And Pylyp Dukhov's answer to that topic recommended utilizing CADisplayLink to calculate the position for each frame and provided a workable solution that did return the real time values when transitioning.
But I'm so unfamiliar with CADisplayLink and creating custom animations that I'm not sure I'll be able to bend it to function the way I want it to.
So this is the animation I want to achieve using CADisplayLink that animates the orange circle view in a circular motion using its position coordinates and repeats forever:
Here is the SwiftUI code:
struct CircleView: View {
#Binding var moveClockwise: Bool
#Binding var duration: Double // Works as speed, since it repeats forever
let geo: GeometryProxy
var body: some View {
ZStack {
Circle()
.stroke()
.frame(width: geo.size.width, height: geo.size.width, alignment: .center)
//MARK: - What I have with SwiftUI animation
Circle()
.fill(.orange)
.frame(width: 35, height: 35, alignment: .center)
.offset(x: -CGFloat(geo.size.width / 2))
.rotationEffect(.degrees(moveClockwise ? 360 : 0))
.animation(
.linear(duration: duration)
.repeatForever(autoreverses: false), value: moveClockwise
)
//MARK: - What I need with CADisplayLink
// Circle()
// .fill(.orange)
// .frame(width: 35, height: 35, alignment: .center)
// .position(CGPoint(x: pos.realTimeX, y: realTimeY))
Button("Start Clockwise") {
moveClockwise = true
// pos.startMovement
}.foregroundColor(.orange)
}.fixedSize()
}
}
struct ContentView: View {
#State private var moveClockwise = false
#State private var duration = 2.0 // Works as speed, since it repeats forever
var body: some View {
VStack {
GeometryReader { geo in
CircleView(moveClockwise: $moveClockwise, duration: $duration, geo: geo)
}
}.padding(20)
}
}
This is what I have currently with CADisplayLink, I added the coordinates to make a circle and that’s about it & it doesn’t repeat forever like the gif does:
Here is the CADisplayLink + real-time coordinate version that I’ve tackled and got lost:
struct Point: View {
var body: some View {
Circle()
.fill(.orange)
.frame(width: 35, height: 35, alignment: .center)
}
}
struct ContentView: View {
#StateObject var P: Position = Position()
var body: some View {
VStack {
ZStack {
Circle()
.stroke()
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width, alignment: .center)
Point()
.position(x: P.realtimePosition.x, y: P.realtimePosition.y)
}
Text("X: \(P.realtimePosition.x), Y: \(P.realtimePosition.y)")
}.onAppear() {
P.startMovement()
}
}
}
class Position: ObservableObject, Equatable {
struct AnimationInfo {
let startDate: Date
let duration: TimeInterval
let startPoint: CGPoint
let endPoint: CGPoint
func point(at date: Date) -> (point: CGPoint, finished: Bool) {
let progress = CGFloat(max(0, min(1, date.timeIntervalSince(startDate) / duration)))
return (
point: CGPoint(
x: startPoint.x + (endPoint.x - startPoint.x) * progress,
y: startPoint.y + (endPoint.y - startPoint.y) * progress
),
finished: progress == 1
)
}
}
#Published var realtimePosition = CGPoint.zero
private var mainTimer: Timer = Timer()
private var executedTimes: Int = 0
private lazy var displayLink: CADisplayLink = {
let displayLink = CADisplayLink(target: self, selector: #selector(displayLinkAction))
displayLink.add(to: .main, forMode: .default)
return displayLink
}()
private let animationDuration: TimeInterval = 0.1
private var animationInfo: AnimationInfo?
private var coordinatesPoints: [CGPoint] {
let screenWidth = UIScreen.main.bounds.width
let screenHeight = UIScreen.main.bounds.height
// great progress haha
let radius: Double = Double(screenWidth / 2)
let center = CGPoint(x: screenWidth / 2, y: screenHeight / 2)
var coordinates: [CGPoint] = []
for i in stride(from: 1, to: 360, by: 10) {
let radians = Double(i) * Double.pi / 180 // raiments = degrees * pI / 180
let x = Double(center.x) + radius * cos(radians)
let y = Double(center.y) + radius * sin(radians)
coordinates.append(CGPoint(x: x, y: y))
}
return coordinates
}
// Conform to Equatable protocol
static func ==(lhs: Position, rhs: Position) -> Bool {
// not sure why would you need Equatable for an observable object?
// this is not how it determines changes to update the view
if lhs.realtimePosition == rhs.realtimePosition {
return true
}
return false
}
func startMovement() {
mainTimer = Timer.scheduledTimer(
timeInterval: 0.1,
target: self,
selector: #selector(movePoint),
userInfo: nil,
repeats: true
)
}
#objc func movePoint() {
if (executedTimes == coordinatesPoints.count) {
mainTimer.invalidate()
return
}
animationInfo = AnimationInfo(
startDate: Date(),
duration: animationDuration,
startPoint: realtimePosition,
endPoint: coordinatesPoints[executedTimes]
)
displayLink.isPaused = false
executedTimes += 1
}
#objc func displayLinkAction() {
guard
let (point, finished) = animationInfo?.point(at: Date())
else {
displayLink.isPaused = true
return
}
realtimePosition = point
if finished {
displayLink.isPaused = true
animationInfo = nil
}
}
}
Inside Position you're calculating position related to whole screen. But .position modifier requires value related to the parent view size.
You need to make your calculations based on the parent size, you can use such sizeReader for this purpose:
extension View {
func sizeReader(_ block: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometry in
Color.clear
.onAppear {
block(geometry.size)
}
.onChange(of: geometry.size, perform: block)
}
)
}
}
Usage:
ZStack {
Circle()
.stroke()
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width)
Point()
.position(x: P.realtimePosition.x, y: P.realtimePosition.y)
}
.sizeReader { size in
P.containerSize = size
}
Also CADisplayLink is not used in the right way. The whole point of this tool is that it's already called on each frame, so you can calculate real time position, so your animation is gonna be really smooth, and you don't need a timer or pre-calculated values for only 180(or any other number) positions.
In the linked answer timer was used because a delay was needed between animations, but in your case the code can be greatly simplified:
class Position: ObservableObject {
#Published var realtimePosition = CGPoint.zero
var containerSize: CGSize?
private lazy var displayLink: CADisplayLink = {
let displayLink = CADisplayLink(target: self, selector: #selector(displayLinkAction))
displayLink.add(to: .main, forMode: .default)
displayLink.isPaused = true
return displayLink
}()
private var startDate: Date?
func startMovement() {
startDate = Date()
displayLink.isPaused = false
}
let animationDuration: TimeInterval = 5
#objc func displayLinkAction() {
guard
let containerSize = containerSize,
let timePassed = startDate?.timeIntervalSinceNow,
case let progress = -timePassed / animationDuration,
progress <= 1
else {
displayLink.isPaused = true
startDate = nil
return
}
let frame = CGRect(origin: .zero, size: containerSize)
let radius = frame.midX
let radians = CGFloat(progress) * 2 * .pi
realtimePosition = CGPoint(
x: frame.midX + radius * cos(radians),
y: frame.midY + radius * sin(radians)
)
}
}
I've tried to make more simplified the implementation, here is the SwiftUI code,
struct RotatingDotAnimation: View {
#State private var moveClockwise = false
#State private var duration = 1.0 // Works as speed, since it repeats forever
var body: some View {
ZStack {
Circle()
.stroke(lineWidth: 4)
.foregroundColor(.white.opacity(0.5))
.frame(width: 150, height: 150, alignment: .center)
Circle()
.fill(.white)
.frame(width: 18, height: 18, alignment: .center)
.offset(x: -63)
.rotationEffect(.degrees(moveClockwise ? 360 : 0))
.animation(.easeInOut(duration: duration).repeatForever(autoreverses: false),
value: moveClockwise
)
}
.onAppear {
self.moveClockwise.toggle()
}
}
}
It'll basically create animation like this,
enter image description here

Is it possible to make dynamically VStack in the HStack when screen width ends [duplicate]

Is it possible that the blue tags (which are currently truncated) are displayed completely and then it automatically makes a line break?
NavigationLink(destination: GameListView()) {
VStack(alignment: .leading, spacing: 5){
// Name der Sammlung:
Text(collection.name)
.font(.headline)
// Optional: Für welche Konsolen bzw. Plattformen:
HStack(alignment: .top, spacing: 10){
ForEach(collection.platforms, id: \.self) { platform in
Text(platform)
.padding(.all, 5)
.font(.caption)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
.lineLimit(1)
}
}
}
.padding(.vertical, 10)
}
Also, there should be no line breaks with in the blue tags:
That's how it should look in the end:
Here is some approach of how this could be done using alignmentGuide(s). It is simplified to avoid many code post, but hope it is useful.
Update: There is also updated & improved variant of below solution in my answer for SwiftUI HStack with wrap and dynamic height
This is the result:
And here is full demo code (orientation is supported automatically):
import SwiftUI
struct TestWrappedLayout: View {
#State var platforms = ["Ninetendo", "XBox", "PlayStation", "PlayStation 2", "PlayStation 3", "PlayStation 4"]
var body: some View {
GeometryReader { geometry in
self.generateContent(in: geometry)
}
}
private func generateContent(in g: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ZStack(alignment: .topLeading) {
ForEach(self.platforms, id: \.self) { platform in
self.item(for: platform)
.padding([.horizontal, .vertical], 4)
.alignmentGuide(.leading, computeValue: { d in
if (abs(width - d.width) > g.size.width)
{
width = 0
height -= d.height
}
let result = width
if platform == self.platforms.last! {
width = 0 //last item
} else {
width -= d.width
}
return result
})
.alignmentGuide(.top, computeValue: {d in
let result = height
if platform == self.platforms.last! {
height = 0 // last item
}
return result
})
}
}
}
func item(for text: String) -> some View {
Text(text)
.padding(.all, 5)
.font(.body)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
}
}
struct TestWrappedLayout_Previews: PreviewProvider {
static var previews: some View {
TestWrappedLayout()
}
}
For me, none of the answers worked. Either because I had different types of elements or because elements around were not being positioned correctly. Therefore, I ended up implementing my own WrappingHStack which can be used in a very similar way to HStack. You can find it at GitHub: WrappingHStack.
Here is an example:
Code:
WrappingHStack {
Text("WrappingHStack")
.padding()
.font(.title)
.border(Color.black)
Text("can handle different element types")
Image(systemName: "scribble")
.font(.title)
.frame(width: 200, height: 20)
.background(Color.purple)
Text("and loop")
.bold()
WrappingHStack(1...20, id:\.self) {
Text("Item: \($0)")
.padding(3)
.background(Rectangle().stroke())
}.frame(minWidth: 250)
}
.padding()
.border(Color.black)
I've had ago at creating what you need.
Ive used HStack's in a VStack.
You pass in a geometryProxy which is used for determining the maximum row width.
I went with passing this in so it would be usable within a scrollView
I wrapped the SwiftUI Views in a UIHostingController to get a size for each child.
I then loop through the views adding them to the row until it reaches the maximum width, in which case I start adding to a new row.
This is just the init and final stage combining and outputting the rows in the VStack
struct WrappedHStack<Content: View>: View {
private let content: [Content]
private let spacing: CGFloat = 8
private let geometry: GeometryProxy
init(geometry: GeometryProxy, content: [Content]) {
self.content = content
self.geometry = geometry
}
var body: some View {
let rowBuilder = RowBuilder(spacing: spacing,
containerWidth: geometry.size.width)
let rowViews = rowBuilder.generateRows(views: content)
let finalView = ForEach(rowViews.indices) { rowViews[$0] }
VStack(alignment: .center, spacing: 8) {
finalView
}.frame(width: geometry.size.width)
}
}
extension WrappedHStack {
init<Data, ID: Hashable>(geometry: GeometryProxy, #ViewBuilder content: () -> ForEach<Data, ID, Content>) {
let views = content()
self.geometry = geometry
self.content = views.data.map(views.content)
}
init(geometry: GeometryProxy, content: () -> [Content]) {
self.geometry = geometry
self.content = content()
}
}
The magic happens in here
extension WrappedHStack {
struct RowBuilder {
private var spacing: CGFloat
private var containerWidth: CGFloat
init(spacing: CGFloat, containerWidth: CGFloat) {
self.spacing = spacing
self.containerWidth = containerWidth
}
func generateRows<Content: View>(views: [Content]) -> [AnyView] {
var rows = [AnyView]()
var currentRowViews = [AnyView]()
var currentRowWidth: CGFloat = 0
for (view) in views {
let viewWidth = view.getSize().width
if currentRowWidth + viewWidth > containerWidth {
rows.append(createRow(for: currentRowViews))
currentRowViews = []
currentRowWidth = 0
}
currentRowViews.append(view.erasedToAnyView())
currentRowWidth += viewWidth + spacing
}
rows.append(createRow(for: currentRowViews))
return rows
}
private func createRow(for views: [AnyView]) -> AnyView {
HStack(alignment: .center, spacing: spacing) {
ForEach(views.indices) { views[$0] }
}
.erasedToAnyView()
}
}
}
and here's extensions I used
extension View {
func erasedToAnyView() -> AnyView {
AnyView(self)
}
func getSize() -> CGSize {
UIHostingController(rootView: self).view.intrinsicContentSize
}
}
You can see the full code with some examples here:
https://gist.github.com/kanesbetas/63e719cb96e644d31bf027194bf4ccdb
I have something like this code (rather long). In simple scenarios it works ok, but in deep nesting with geometry readers it doesn't propagate its size well.
It would be nice if this views wraps and flows like Text() extending parent view content, but it seems to have explicitly set its height from parent view.
https://gist.github.com/michzio/a0b23ee43a88cbc95f65277070167e29
Here is the most important part of the code (without preview and test data)
private func flow(in geometry: GeometryProxy) -> some View {
print("Card geometry: \(geometry.size.width) \(geometry.size.height)")
return ZStack(alignment: .topLeading) {
//Color.clear
ForEach(data, id: self.dataId) { element in
self.content(element)
.geometryPreference(tag: element\[keyPath: self.dataId\])
/*
.alignmentGuide(.leading) { d in
print("Element: w: \(d.width), h: \(d.height)")
if (abs(width - d.width) > geometry.size.width)
{
width = 0
height -= d.height
}
let result = width
if element\[keyPath: self.dataId\] == self.data.last!\[keyPath: self.dataId\] {
width = 0 //last item
} else {
width -= d.width
}
return result
}
.alignmentGuide(.top) { d in
let result = height
if element\[keyPath: self.dataId\] == self.data.last!\[keyPath: self.dataId\] {
height = 0 // last item
}
return result
}*/
.alignmentGuide(.top) { d in
self.alignmentGuides\[element\[keyPath: self.dataId\]\]?.y ?? 0
}
.alignmentGuide(.leading) { d in
self.alignmentGuides\[element\[keyPath: self.dataId\]\]?.x ?? 0
}
}
}
.background(Color.pink)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
//.animation(self.loaded ? .linear(duration: 1) : nil)
.onPreferenceChange(_GeometryPreferenceKey.self, perform: { preferences in
DispatchQueue.main.async {
let (alignmentGuides, totalHeight) = self.calculateAlignmentGuides(preferences: preferences, geometry: geometry)
self.alignmentGuides = alignmentGuides
self.totalHeight = totalHeight
self.availableWidth = geometry.size.width
}
})
}
func calculateAlignmentGuides(preferences: \[_GeometryPreference\], geometry: GeometryProxy) -> (\[AnyHashable: CGPoint\], CGFloat) {
var alignmentGuides = \[AnyHashable: CGPoint\]()
var width: CGFloat = 0
var height: CGFloat = 0
var rowHeights: Set<CGFloat> = \[\]
preferences.forEach { preference in
let elementWidth = spacing + preference.rect.width
if width + elementWidth >= geometry.size.width {
width = 0
height += (rowHeights.max() ?? 0) + spacing
//rowHeights.removeAll()
}
let offset = CGPoint(x: 0 - width, y: 0 - height)
print("Alignment guides offset: \(offset)")
alignmentGuides\[preference.tag\] = offset
width += elementWidth
rowHeights.insert(preference.rect.height)
}
return (alignmentGuides, height + (rowHeights.max() ?? 0))
}
}
I had the same problem I've, to solve it I pass the object item to a function which first creates the view for the item, then through the UIHostController I will calculate the next position based on the items width. the items view is then returned by the function.
import SwiftUI
class TestItem: Identifiable {
var id = UUID()
var str = ""
init(str: String) {
self.str = str
}
}
struct AutoWrap: View {
var tests: [TestItem] = [
TestItem(str:"Ninetendo"),
TestItem(str:"XBox"),
TestItem(str:"PlayStation"),
TestItem(str:"PlayStation 2"),
TestItem(str:"PlayStation 3"),
TestItem(str:"random"),
TestItem(str:"PlayStation 4"),
]
var body: some View {
var curItemPos: CGPoint = CGPoint(x: 0, y: 0)
var prevItemWidth: CGFloat = 0
return GeometryReader { proxy in
ZStack(alignment: .topLeading) {
ForEach(tests) { t in
generateItem(t: t, curPos: &curItemPos, containerProxy: proxy, prevItemWidth: &prevItemWidth)
}
}.padding(5)
}
}
func generateItem(t: TestItem, curPos: inout CGPoint, containerProxy: GeometryProxy, prevItemWidth: inout CGFloat, hSpacing: CGFloat = 5, vSpacing: CGFloat = 5) -> some View {
let viewItem = Text(t.str).padding([.leading, .trailing], 15).background(Color.blue).cornerRadius(25)
let itemWidth = UIHostingController(rootView: viewItem).view.intrinsicContentSize.width
let itemHeight = UIHostingController(rootView: viewItem).view.intrinsicContentSize.height
let newPosX = curPos.x + prevItemWidth + hSpacing
let newPosX2 = newPosX + itemWidth
if newPosX2 > containerProxy.size.width {
curPos.x = hSpacing
curPos.y += itemHeight + vSpacing
} else {
curPos.x = newPosX
}
prevItemWidth = itemWidth
return viewItem.offset(x: curPos.x, y: curPos.y)
}
}
struct AutoWrap_Previews: PreviewProvider {
static var previews: some View {
AutoWrap()
}
}
iOS 16 has a new Layout protocol that's perfect for that task. I've written a library with the line-wrapping behavior. It can handle different types of subviews and alignment guide values.
You need to handle line configurations right after Text View. Don't use lineLimit(1) if you need multiple lines.
HStack(alignment: .top, spacing: 10){
ForEach(collection.platforms, id: \.self) { platform in
Text(platform)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(10)
.multilineTextAlignment(.leading)
.padding(.all, 5)
.font(.caption)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
}
}

Picker selection and translation conflict in SwiftUI

I have a movable VStack which carry a Picker. When I want chose deferent option from Picker I cannot, because SwiftUI thinks I want use DragGesture, therefor my Picker is lockdown! My DragGesture has minimumDistance: 0 but it does not solve issue when I change this value also, from other hand I like to have minimumDistance: 0 so it is not even an option for me to solving issue with increasing minimumDistance, so I need help to find a way, thanks.
struct ContentView: View {
var body: some View {
StyleView()
}
}
struct StyleView: View {
#State private var location: CGSize = CGSize()
#GestureState private var translation: CGSize = CGSize()
#State private var styleIndex: Int = 0
let styles: [String] = ["a", "b", "c"]
var body: some View {
VStack {
Picker(selection: $styleIndex, label: Text("Style")) {
ForEach(styles.indices, id:\.self) { index in
Text(styles[index].description)
}
}
.pickerStyle(SegmentedPickerStyle())
.padding()
Text("selected style: " + styles[styleIndex])
}
.padding()
.background(Color.red)
.cornerRadius(10)
.padding()
.position(x: location.width + translation.width + 200, y: location.height + translation.height + 100)
.gesture(DragGesture(minimumDistance: 0)
.updating($translation) { value, state, _ in
state = value.translation
}
.onEnded { value in
location = CGSize(width: location.width + value.translation.width, height: location.height + value.translation.height)
})
}
}
DragGesture triggers when the user presses down on a view and move at least a certain distance away. So, this creates a picker with a drag gesture that triggers when the user moves it at least 10 points (it should be greater than 0 otherwise how can it know about the tap and the drag)
struct StyleView: View {
#State private var location: CGSize = CGSize()
#GestureState private var translation: CGSize = CGSize()
#State private var styleIndex: Int = 0
let styles: [String] = ["a", "b", "c"]
var body: some View {
VStack {
Picker(selection: $styleIndex, label: Text("Style")) {
ForEach(styles.indices, id:\.self) { index in
Text(styles[index].description)
}
}.gesture(DragAndTapGesture(count: styles.count, selected: $styleIndex).horizontal)
.pickerStyle(SegmentedPickerStyle())
.padding()
Text("selected style: " + styles[styleIndex])
}
.padding()
.background(Color.red)
.cornerRadius(10)
.padding()
.position(x: location.width + translation.width + 200, y: location.height + translation.height + 100)
.gesture(DragGesture(minimumDistance: 1)
.updating($translation) { value, state, _ in
state = value.translation
}
.onEnded { value in
location = CGSize(width: location.width + value.translation.width, height: location.height + value.translation.height)
})
}
}
struct DragAndTapGesture {
var count: Int
#Binding var selected: Int
init(count: Int, selected: Binding<Int>) {
self.count = count
self._selected = selected
}
var horizontal: some Gesture {
DragGesture().onEnded { value in
if -value.predictedEndTranslation.width > UIScreen.main.bounds.width / 2, self.selected < self.count - 1 {
self.selected += 1
}
if value.predictedEndTranslation.width > UIScreen.main.bounds.width / 2, self.selected > 0 {
self.selected -= 1
}
}
}
}

How to animate views in turn

I want to animate the arriving of views on the screen, in turn, one by one. Now my application draws circles and they arrive on the screen at the same time. But I would like it if the first circle will take its position and only after this the second circle will start its animation and etc. What solutions does this problem have?
import SwiftUI
struct ContentView: View {
var body: some View {
GenrealView()
}
}
struct GenrealView: View {
#State var hide = false
var body: some View {
giveViewForBody()
}
func giveViewForBody() -> some View {
ZStack {
drawCircles()
Button(action: {
self.hide.toggle()
}) {
Text(hide ? "Show circles" : "Hide circles")
}.padding(50)
}
}
func drawCircles(times: Int = 4) -> some View {
ForEach(0..<times) { _ in
Circle()
.fill(Color.green)
.frame(width: 100, height: 100)
.position(x: -100, y: -100)
.offset(x: CGFloat(hide ? 0 : 100 + Int.random(in: 100...300)),
y: CGFloat(hide ? 0 : 200 + Int.random(in: 50...600)))
.animation(.easeIn(duration: 2.0))
}
}
}
Add a .delay to each Circle() and make the delay larger for each successive one. Add index in to your ForEach loop and then make the delay .delay(2.0 * Double(index)):
func drawCircles(times: Int = 4) -> some View {
ForEach(0..<times) { index in
Circle()
.fill(Color.green)
.frame(width: 100, height: 100)
.position(x: -100, y: -100)
.offset(x: CGFloat(hide ? 0 : 100 + Int.random(in: 100...300)),
y: CGFloat(hide ? 0 : 200 + Int.random(in: 50...600)))
.animation(Animation.easeIn(duration: 2.0).delay(2.0 * Double(index)))
}
}

SwiftUI - Constructing a LazyVGrid with expandable views

I'm trying to construct a two-column grid of quadratic views from an array of colors where one view expands to the size of four small views when clicked.
Javier from swiftui-lab.com gave me kind of a breakthrough with the idea of adding Color.clear as a "fake" view inside the ForEach to trick the VGrid into making space for the expanded view. This works fine for the boxes on the left of the grid. The boxes on the right, however, give me a no ends of trouble because they expand to the right and don't cause the VGrid to reallign properly:
I've tried several things like swapping the colors in the array, rotating the whole grid when one of the views on the right is clicked, adding varying numbers of Color.clear views - nothing has done the trick so far.
Here's the current code:
struct ContentView: View {
#State private var selectedColor : UIColor? = nil
let colors : [UIColor] = [.red, .yellow, .green, .orange, .blue, .magenta, .purple, .black]
private let padding : CGFloat = 10
var body: some View {
GeometryReader { proxy in
ScrollView {
LazyVGrid(columns: [
GridItem(.fixed(proxy.size.width / 2 - 5), spacing: padding, alignment: .leading),
GridItem(.fixed(proxy.size.width / 2 - 5))
], spacing: padding) {
ForEach(0..<colors.count, id: \.self) { id in
if selectedColor == colors[id] && id % 2 != 0 {
Color.clear
}
RectangleView(proxy: proxy, colors: colors, id: id, selectedColor: selectedColor, padding: padding)
.onTapGesture {
withAnimation{
if selectedColor == colors[id] {
selectedColor = nil
} else {
selectedColor = colors[id]
}
}
}
if selectedColor == colors[id] {
Color.clear
Color.clear
Color.clear
}
}
}
}
}.padding(.all, 10)
}
}
RectangleView:
struct RectangleView: View {
var proxy: GeometryProxy
var colors : [UIColor]
var id: Int
var selectedColor : UIColor?
var padding : CGFloat
var body: some View {
Color(colors[id])
.frame(width: calculateFrame(for: id), height: calculateFrame(for: id))
.clipShape(RoundedRectangle(cornerRadius: 20))
.offset(y: resolveOffset(for: id))
}
// Used to offset the boxes after the expanded one to compensate for missing padding
func resolveOffset(for id: Int) -> CGFloat {
guard let selectedColor = selectedColor, let selectedIndex = colors.firstIndex(of: selectedColor) else { return 0 }
if id > selectedIndex {
return -(padding * 2)
}
return 0
}
func calculateFrame(for id: Int) -> CGFloat {
selectedColor == colors[id] ? proxy.size.width : proxy.size.width / 2 - 5
}
}
I would be really grateful if you could point me in the direction of what I'm doing wrong.
P.S. If you run the code, you'll notice that the last black box is also not behaving as expected. That's another issue that I've not been able to solve thus far.
After giving up on the LazyVGrid to do the job, I kind of "hacked" two simple VStacks to be contained in a ParallelStackView. It lacks the beautiful crossover animation a LazyVGrid has and can only be implemented for two columns, but gets the job done - kind of. This is obviously a far cry from an elegant solution but I needed a workaround, so for anyone working on the same issue, here's the code (implemented as generic over the type it contains):
struct ParallelStackView<T: Equatable, Content: View>: View {
let padding : CGFloat
let elements : [T]
#Binding var currentlySelectedItem : T?
let content : (T) -> Content
#State private var selectedElement : T? = nil
#State private var selectedSecondElement : T? = nil
var body: some View {
let (transformedFirstArray, transformedSecondArray) = transformArray(array: elements)
func resolveClearViewHeightForFirstArray(id: Int, for proxy: GeometryProxy) -> CGFloat {
transformedSecondArray[id+1] == selectedSecondElement || (transformedSecondArray[1] == selectedSecondElement && id == 0) ? proxy.size.width + padding : 0
}
func resolveClearViewHeightForSecondArray(id: Int, for proxy: GeometryProxy) -> CGFloat {
transformedFirstArray[id+1] == selectedElement || (transformedFirstArray[1] == selectedElement && id == 0) ? proxy.size.width + padding : 0
}
return GeometryReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
VStack(alignment: .leading, spacing: padding / 2) {
ForEach(0..<transformedFirstArray.count, id: \.self) { id in
if transformedFirstArray[id] == nil {
Color.clear.frame(
width: proxy.size.width / 2 - padding / 2,
height: resolveClearViewHeightForFirstArray(id: id, for: proxy))
} else {
RectangleView(proxy: proxy, elements: transformedFirstArray, id: id, selectedElement: selectedElement, padding: padding, content: content)
.onTapGesture {
withAnimation(.spring()){
if selectedElement == transformedFirstArray[id] {
selectedElement = nil
currentlySelectedItem = nil
} else {
selectedSecondElement = nil
selectedElement = transformedFirstArray[id]
currentlySelectedItem = selectedElement
}
}
}
}
}
}
VStack(alignment: .leading, spacing: padding / 2) {
ForEach(0..<transformedSecondArray.count, id: \.self) { id in
if transformedSecondArray[id] == nil {
Color.clear.frame(
width: proxy.size.width / 2 - padding / 2,
height: resolveClearViewHeightForSecondArray(id: id, for: proxy))
} else {
RectangleView(proxy: proxy, elements: transformedSecondArray, id: id, selectedElement: selectedSecondElement, padding: padding, content: content)
.onTapGesture {
withAnimation(.spring()){
if selectedSecondElement == transformedSecondArray[id] {
selectedSecondElement = nil
currentlySelectedItem = nil
} else {
selectedElement = nil
selectedSecondElement = transformedSecondArray[id]
currentlySelectedItem = selectedSecondElement
}
}
}.rotation3DEffect(.init(degrees: 180), axis: (x: 0, y: 1, z: 0))
}
}
}
// You need to rotate the second VStack for it to expand in the correct direction (left).
// As now all text would be displayed as mirrored, you have to reverse that rotation "locally"
// with a .rotation3DEffect modifier (see 4 lines above).
.rotate3D()
.offset(x: resolveOffset(for: proxy))
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .topTrailing)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}.padding(10)
}
func resolveOffset(for proxy: GeometryProxy) -> CGFloat {
selectedSecondElement == nil ? proxy.size.width / 2 - padding / 2 : proxy.size.width
}
// Transform the original array to alternately contain nil and real values
// for the Color.clear views. You could just as well use other "default" values
// but I thought nil was quite explicit and makes it easier to understand what
// is going on. Then you split the transformed array into two sub-arrays for
// the VStacks:
func transformArray<T: Equatable>(array: [T]) -> ([T?], [T?]) {
var arrayTransformed : [T?] = []
array.map { element -> (T?, T?) in
return (nil, element)
}.forEach {
arrayTransformed.append($0.0)
arrayTransformed.append($0.1)
}
arrayTransformed = arrayTransformed.reversed()
var firstTransformedArray : [T?] = []
var secondTransformedArray : [T?] = []
for i in 0...arrayTransformed.count / 2 {
guard let nilValue = arrayTransformed.popLast(), let element = arrayTransformed.popLast() else { break }
if i % 2 == 0 {
firstTransformedArray += [nilValue, element]
} else {
secondTransformedArray += [nilValue, element]
}
}
return (firstTransformedArray, secondTransformedArray)
}
struct RectangleView: View {
let proxy: GeometryProxy
let elements : [T?]
let id: Int
let selectedElement : T?
let padding : CGFloat
let content : (T) -> Content
var body: some View {
content(elements[id]!)
.frame(width: calculateFrame(for: id), height: calculateFrame(for: id))
.clipShape(RoundedRectangle(cornerRadius: 20))
}
func calculateFrame(for id: Int) -> CGFloat {
selectedElement == elements[id] ? proxy.size.width : proxy.size.width / 2 - 5
}
}
}
extension View {
func rotate3D() -> some View {
modifier(StackRotation())
}
}
struct StackRotation: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
let c = CATransform3DIdentity
return ProjectionTransform(CATransform3DRotate(c, .pi, 0, 1, 0))
}
}