SwiftUI access a view created in a ForEach loop - ios

Is there a way to access a view created in a ForEach loop? I'm creating views (Rectangles) with this struct on this loop. I want to change the fill color of the rects upon the tapped gestures.
struct DisplayingRect:Identifiable {
var id = UUID()
var width:CGFloat = 0
var height:CGFloat = 0
var xAxis:CGFloat = 0
var yAxis:CGFloat = 0
init(width:CGFloat, height:CGFloat, xAxis:CGFloat, yAxis:CGFloat) {
self.width = width
self.height = height
self.xAxis = xAxis
self.yAxis = yAxis
}
}
ForEach(self.rects) { rect in
Rectangle()
.fill(Color.init(.sRGB, red: 1, green: 0, blue: 0, opacity: 0.2))
.frame(width: rect.width, height: rect.height)
.offset(x: rect.xAxis, y: rect.yAxis)
.id(rect.id)
.onTapGesture {
print("Clicked")
self.rectTapped = rect.width
print(rect.width)
print(rect.id)
if !self.didTap {
self.didTap = true
} else {
self.didTap = false
}
}
I can assign each view with an id setting its id property, but I don't know where they are stored or how to modify them upon the click. I can create function that returns a view (Rectangle) and store them in an array, and display them in the screen, but again I don't know how to access them and modify the one I want.

Keep a #State to track which indices are highlighted then make your color a function of that state. Here is an example with animation:
struct ContentView: View {
#State private var selectedIndices = Set<Int>()
var body: some View {
ForEach (0..<3) { index in
Color(self.selectedIndices.contains(index) ? .yellow : .blue)
.frame(width: 200, height: 200)
.animation(.easeInOut(duration: 0.25))
.onTapGesture {
if self.selectedIndices.contains(index) {
self.selectedIndices.remove(index)
} else {
self.selectedIndices.insert(index)
}
}
}
}
}

you can do it like this:
struct DisplayingRect:Identifiable, Hashable {
static var counter = 0
var id : Int = DisplayingRect.counter
var width:CGFloat = 0
var height:CGFloat = 0
var xAxis:CGFloat = 0
var yAxis:CGFloat = 0
var color: Color = Color.red
init(width:CGFloat, height:CGFloat, xAxis:CGFloat, yAxis:CGFloat) {
self.width = width
self.height = height
self.xAxis = xAxis
self.yAxis = yAxis
DisplayingRect.counter = DisplayingRect.counter + 1
}
}
struct ContentView : View {
#State var rects : [DisplayingRect] = [
DisplayingRect(width: 30, height: 30, xAxis: 0, yAxis: 0),
DisplayingRect(width: 50, height: 50, xAxis: 50, yAxis: 50)
]
func setColorToID(_ id: Int) {
rects[id].color = Color.blue
}
var body: some View {
ForEach(self.rects, id: \.self) { rect in
Rectangle()
.fill(rect.color)
.frame(width: rect.width, height: rect.height)
.offset(x: rect.xAxis, y: rect.yAxis)
.id(rect.id)
.onTapGesture {
print(rect.id)
self.setColorToID(rect.id)
}
}
}
}

SwiftUI encourages a declarative approach – you shouldn't need to (and in fact can't) access any view directly to store a reference to it. Your views can be given data, and whenever that data changes, they'll update.
In this case, you could have your DisplayingRect store a color property, then have the tap gesture on each Rectangle look up the right struct in your rects array by ID, and modify the color property.
To separate out the logic from your view and make more of this unit testable, you might want to create some kind of view model class that encompasses this, but putting it all inside your view would work without these benefits.
This approach could look something like this (test locally & working):
struct DisplayingRect: Identifiable {
let id = UUID()
var color = Color.red
var width: CGFloat
var height: CGFloat
var xAxis: CGFloat
var yAxis: CGFloat
init(
width: CGFloat,
height: CGFloat,
xAxis: CGFloat = 0,
yAxis: CGFloat = 0)
{
self.width = width
self.height = height
self.xAxis = xAxis
self.yAxis = yAxis
}
}
final class ContentViewModel: ObservableObject {
#Published
private(set) var rects: [DisplayingRect] = [
.init(width: 100, height: 100),
.init(width: 100, height: 100),
.init(width: 100, height: 100)
]
func didTapRectangle(id: UUID) {
guard let rectangleIndex = rects.firstIndex(where: { $0.id == id }) else {
return
}
rects[rectangleIndex].color = .blue
}
}
struct ContentView: View {
#ObservedObject
var viewModel = ContentViewModel()
var body: some View {
VStack {
ForEach(viewModel.rects) { rect in
Rectangle()
.fill(rect.color)
.frame(width: rect.width, height: rect.height)
.offset(x: rect.xAxis, y: rect.yAxis)
.onTapGesture {
self.viewModel.didTapRectangle(id: rect.id)
}
}
}
}
}
In this case, the #ObservedObject property wrapper along with ObservableObject protocol allow the view to update itself whenever data it uses from viewModel is changed. To automatically signal properties that should cause the view to refresh, the #Published property wrapper is used.
https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-observedobject-to-manage-state-from-external-objects

Related

Binding variable across two views to control animation speed change

Background
I'm following the excellent tutorial by Jean-Marc Boullianne on animating colour in change in swift. I would like to enhance this example by adding a slider that would allow me to control the animation speed. For that purpose I've defined the relevant UI elements in the ContentView.swift and I would like to use the values in the SplashView.swift that produces the animation.
Problem
The value that I'm attempting to pass via slider is disregarded and the animation keeps reproducing with the same default speed.
Code
ContentView.swift
A note: colours are defined in the assets catalogue for the purpose of example any values will do.
import SwiftUI
struct ContentView: View {
// Color variables for the animation
var colors: [Color] = [Color("AnimationColor1"),
Color("AnimationColor2"),
Color("AnimationColor3"),
Color("AnimationColor4")]
#State var index: Int = 0
#State var progress: CGFloat = 0
#State var animationDuration: Double = 0.5
var body: some View {
VStack {
SplashView(animationType: .leftToRight, color: self.colors[self.index])
.frame(width: 200, height: 100, alignment: .center)
.cornerRadius(10)
.shadow(color: Color("ShadowColor"), radius: 10, x: 0.0, y: 0.0)
Button(action: {
self.index = (self.index + 1) % self.colors.count
}, label: {
Text("Change Colour")
.padding(.top, 20)
})
Slider(value: $animationDuration, in: 0.0...1.0)
Text("Animation duration \(animationDuration)")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
SplashView.swift
I understand that I'm using the #Binding wrong...
//
// SplashView.swift
// ColourChange
//
//
import SwiftUI
/**
Customer observable object
- Parameter color: The color to store
*/
class ColorStore: ObservableObject {
#Published var color: Color
init(color: Color) {
self.color = color
}
}
struct SplashView: View {
// This binds value from the slider option of the swift ui view
#Binding var animationDuration: Double
// Keeping track of the color: animation defaults as per tutorial
#State var layers: [(Color, CGFloat)] = [] // New Color & Progress
var animationType: SplashShape.SplashShapeAnimation
#State private var prevColor: Color // Background colour
#ObservedObject var colorStore: ColorStore
// Those init calls help to deal with the binding problem when the value is called
init(animationType: SplashShape.SplashShapeAnimation,
color: Color,
animationDuration: Binding<Double> = .constant(0.1)) {
self.animationType = animationType
self._prevColor = State<Color>(initialValue: color)
self.colorStore = ColorStore(color: color)
self._animationDuration = animationDuration
}
var body: some View {
// Need to display each layer as an overlay on the Rectangle inside the body variable
Rectangle()
.foregroundColor(self.prevColor) // Current color
// Displaying each layer on top of another
.overlay(
ZStack {
ForEach(layers.indices, id: \.self) { xLayer in
SplashShape(progress: self.layers[xLayer].1, animationType: self.animationType)
.foregroundColor(self.layers[xLayer].0)
}
}
)
.onReceive(self.colorStore.$color, perform: { color in
// Animate color update
self.layers.append((color, 0))
// Exclamation mark in variable calling is necessary to faciliate unwrapping
withAnimation(.easeInOut(duration: self.animationDuration)) {
self.layers[self.layers.count - 1].1 = 1.0
}
})
}
}
SplashShape.swift
For convenience, I have added SplashShape.swift file but that file doesn't differ from the original one available in the tutorial.
import SwiftUI
struct SplashShape: Shape {
public enum SplashShapeAnimation {
case leftToRight
case rightToLeft
}
var progress: CGFloat // Will take values between 0 and 1
var animationType: SplashShapeAnimation
var animatableData: CGFloat {
get { return progress }
set { self.progress = newValue }
}
func path(in rect: CGRect) -> Path {
// We return the correct path after deciding which type of animation is being used
switch animationType {
case .leftToRight:
return leftToRight(rect: rect)
case .rightToLeft:
return rightToLeft(rect: rect)
}
}
func leftToRight(rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: 0, y: 0)) // Top left corner
path.addLine(to: CGPoint(x: rect.width * progress, y: 0)) // Top Right
path.addLine(to: CGPoint(x: rect.width * progress, y: rect.height)) // Bottom right
path.addLine(to: CGPoint(x: 0, y: rect.height)) // Bottom Left
path.closeSubpath()
return path
}
func rightToLeft(rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.width, y: 0))
path.addLine(to: CGPoint(x: rect.width - (rect.width * progress), y: 0))
path.addLine(to: CGPoint(x: rect.width - (rect.width * progress), y: rect.height))
path.addLine(to: CGPoint(x: rect.width, y: rect.height))
path.closeSubpath()
return path
}
}
In your example the animationDuration in SlashView is independent of the ContentView's animationDuration (that is bound to the Slider).
In other words: in your ContentView we do not find the animationDuration in the initialization of SplashView:
SplashView(animationType: .leftToRight, color: self.colors [self.index])
should be:
SplashView(animationType: .leftToRight, color: self.colors [self.index], animationDuration: $animationDuration)
But in reality, if the animationDuration variable is never changed in SplashView, then it shouldn't be declared with #Binding.
In this case you can :
Replace (in SplashView)
#Binding var animationDuration: Double
by
var animationDuration: Double
Change your custom init :
init(animationType: SplashShape.SplashShapeAnimation,
color: Color,
animationDuration: Double = 0.1) {
self.animationType = animationType
self._prevColor = State<Color>(initialValue: color)
self.colorStore = ColorStore(color: color)
self.animationDuration = animationDuration
}
Initialize SplashView(in ContentView), this way :
SplashView(animationType: .leftToRight, color: self.colors[self.index], animationDuration: animationDuration)

Wrap SwiftUI Text into two columns

I am building a widget that will hold some text that is a list of short words and phrases. Something like this:
Because it's a list of short items it would work best if it could wrap into two columns.
Here's the current simple code (with font and spacings removed):
struct WidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
ZStack {
Color(entry.color)
VStack(alignment: .leading) {
Text(entry.name)
Text("Updated in 6 hours")
Text(entry.content)
}
}
}
}
I found this guide to tell me whether or not the text is truncated, but what I need is to know what text has been truncated so that I can add another Text view to the right with the remaining characters. Or ideally use some native method to continue the text between two text views.
This is certainly not ideal but here's what I came up with. The gist is that I used the truncated text paradigm linked in the question to get the available height. Then I use the width of the widget minus padding to iterate through the text until it can no longer fit in half the width.
Some downsides are that (1) The left column must be half or less than the width of the widget, when in reality it could sometimes fit more content if it was greater, (2) it is difficult to be 100% certain the spacings are all accounted for, and (3) had to hardcode the dimensions of the widget.
In any case, hope this helps anyone looking for a similar solution!
Here's the code with spacings and colors removed for clarity:
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
extension View {
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader {
geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
})
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
struct TruncableText: View {
let text: Text
#State private var intrinsicSize: CGSize = .zero
#State private var truncatedSize: CGSize = .zero
let isTruncatedUpdate: (_ isTruncated: Bool, _ truncatedSize: CGSize) -> Void
var body: some View {
text
.readSize { size in
truncatedSize = size
isTruncatedUpdate(truncatedSize != intrinsicSize, size)
}
.background(
text
.fixedSize(horizontal: false, vertical: true)
.hidden()
.readSize { size in
intrinsicSize = size
if truncatedSize != .zero {
isTruncatedUpdate(truncatedSize != intrinsicSize, truncatedSize)
}
})
}
}
/**
- Parameter text: The entire contents of the note
- Parameter size: The size of the text area that was used to initially render the first note
- Parameter widgetWidth: exact width of the widget for the current family/screen size
*/
func partitionText(_ text: String, size: CGSize, widgetWidth: CGFloat) -> (String, String)? {
var part1 = ""
var part2 = text
let colWidth = widgetWidth / 2 - 32 // padding
let colHeight = size.height
// Shouldn't happen but just block against infinite loops
for i in 0...100 {
// Find the first line, or if that doesn't work the first space
var splitAt = part2.firstIndex(of: "\n")
if (splitAt == nil) {
splitAt = part2.firstIndex(of: "\r")
if (splitAt == nil) {
splitAt = part2.firstIndex(of: " ")
}
}
// We have a block of letters remaining. Let's not split it.
if splitAt == nil {
if i == 0 {
// If we haven't split anything yet, just show the text as a single block
return nil
} else {
// Divide what we had
break
}
}
let part1Test = String(text[...text.index(splitAt!, offsetBy: part1.count)])
let part1TestSize = part1Test
.trimmingCharacters(in: .newlines)
.boundingRect(with: CGSize(width: colWidth, height: .infinity),
options: .usesLineFragmentOrigin,
attributes: [.font: UIFont.systemFont(ofSize: 12)],
context: nil)
if (part1TestSize.height > colHeight) {
// We exceeded the limit! return what we have
break;
}
part1 = part1Test
part2 = String(part2[part2.index(splitAt!, offsetBy: 1)...])
}
return (part1.trimmingCharacters(in: .newlines), part2.trimmingCharacters(in: .newlines))
}
func getWidgetWidth(_ family: WidgetFamily) -> CGFloat {
switch family {
case .systemLarge, .systemMedium:
switch UIScreen.main.bounds.size {
case CGSize(width: 428, height: 926): return 364
case CGSize(width: 414, height: 896): return 360
case CGSize(width: 414, height: 736): return 348
case CGSize(width: 390, height: 844): return 338
case CGSize(width: 375, height: 812): return 329
case CGSize(width: 375, height: 667): return 321
case CGSize(width: 360, height: 780): return 329
case CGSize(width: 320, height: 568): return 292
default: return 330
}
default:
switch UIScreen.main.bounds.size {
case CGSize(width: 428, height: 926): return 170
case CGSize(width: 414, height: 896): return 169
case CGSize(width: 414, height: 736): return 159
case CGSize(width: 390, height: 844): return 158
case CGSize(width: 375, height: 812): return 155
case CGSize(width: 375, height: 667): return 148
case CGSize(width: 360, height: 780): return 155
case CGSize(width: 320, height: 568): return 141
default: return 155
}
}
}
struct NoteWidgetEntryView : View {
#State var isTruncated: Bool = false
#State var colOneText: String = ""
#State var colTwoText: String = ""
var entry: Provider.Entry
#Environment(\.widgetFamily) var family: WidgetFamily
var body: some View {
ZStack{
Color(entry.color)
VStack {
Text(entry.name)
Text("Updated 6 hours ago")
if entry.twoColumn {
if (isTruncated) {
HStack {
Text(colOneText).font(.system(size:12))
Text(colTwoText).font(.system(size:12))
}
} else {
TruncableText(text: Text(entry.content).font(.system(size:12))) {
let size = $1
if ($0 && colTwoText == "") {
if let (part1, part2) = partitionText(entry.content, size: size, widgetWidth: getWidgetWidth(family)) {
colOneText = part1
colTwoText = part2
// Only set this if we successfully partitioned the text
isTruncated = true
}
}
}
}
} else {
Text(entry.content).font(.system(size:12))
}
}
}
}
}
Using the views' frames modifier
This can be done using a frame modifier.
Try to create an HStack and each view in it will get the same frame modifier as .frame(minWidth: 0, maxWidth: .infinity).
This will equally distribute the views.
Looking at your code I think this could work.
struct WidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
ZStack {
Color(entry.color)
VStack(alignment: .leading) {
Text(entry.name)
Text("Updated in 6 hours")
// your entry.content needs to be formatted to an HStack
HStack {
Text(entry.content)
.frame(minWidth: 0, maxWidth: .infinity)
Text(entry.content)
.frame(minWidth: 0, maxWidth: .infinity)
}
}
}
}
See this article too:
SwiftUI: Two equal width columns

Drag separators in SwiftUI

How would I add draggable separator lines between Views or UIViews using purely SwiftUI. Is it even possible with SwiftUI, or would I have to fall back on UIKit?
Example screens with separators:
I can't find this kind of stuff in the SwiftUI documentation. Even just enough info to do the top-left two-pane example would be useful.
(Similar questions have been asked here and here , but these are 5 and 7 years old, and deal with Objective-C / UIKit, not Swift / SwiftUI)
Here is a sample that allows horizontal and vertical resizing using grips. Dragging the purple grip resizes horizontally and the orange grip vertically. Both vertical and horizontal sizes are bounded by device resolution. The red pane is always visible, but the grips and other panes can be hidden using a toggle. There is also a reset button to restore, it is only visible when the original state changes. There are other tidbits that are useful and commented inline.
// Resizable panes, red is always visible
struct PanesView: View {
static let startWidth = UIScreen.main.bounds.size.width / 6
static let startHeight = UIScreen.main.bounds.size.height / 5
// update drag width when the purple grip is dragged
#State private var dragWidth : CGFloat = startWidth
// update drag height when the orange grip is dragged
#State private var dragHeight : CGFloat = startHeight
// remember show/hide green and blue panes
#AppStorage("show") var show : Bool = true
// keeps the panes a reasonable size based on device resolution
var minWidth : CGFloat = UIScreen.main.bounds.size.width / 6
let minHeight : CGFloat = UIScreen.main.bounds.size.height / 5
// purple and orange grips are this thick
let thickness : CGFloat = 9
// computed property that shows resize when appropriate
var showResize : Bool {
dragWidth != PanesView.startWidth || dragHeight != PanesView.startHeight
}
// use computed properties to keep the body tidy
var body: some View {
HStack(spacing: 0) {
redPane
// why two show-ifs? the animated one chases the non-animated and adds visual interest
if show {
purpleGrip
}
if show { withAnimation {
VStack(spacing: 0) {
greenPane
orangeGrip
Color.blue.frame(height: dragHeight) // blue pane
}
.frame(width: dragWidth)
} }
}
}
var redPane : some View {
ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)) {
Color.red
// shows and hides the green and blue pane, both grips
Toggle(isOn: $show.animation(), label: {
// change icon depending on toggle position
Image(systemName: show ? "eye" : "eye.slash")
.font(.title)
.foregroundColor(.primary)
})
.frame(width: 100)
.padding()
}
}
var purpleGrip : some View {
Color.purple
.frame(width: thickness)
.gesture(
DragGesture()
.onChanged { gesture in
let screenWidth = UIScreen.main.bounds.size.width
// the framework feeds little deltas as the drag continues updating state
let delta = gesture.translation.width
// make sure drag width stays bounded
dragWidth = max(dragWidth - delta, minWidth)
dragWidth = min(screenWidth - thickness - minWidth, dragWidth)
}
)
}
var greenPane : some View {
ZStack(alignment: Alignment(horizontal: .center, vertical: .top)) {
Color.green
// reset to original size
if showResize { withAnimation {
Button(action: { withAnimation {
dragWidth = UIScreen.main.bounds.size.width / 6
dragHeight = UIScreen.main.bounds.size.height / 5
} }, label: {
Image(systemName: "uiwindow.split.2x1")
.font(.title)
.foregroundColor(.primary)
.padding()
})
.buttonStyle(PlainButtonStyle())
}}
}
}
var orangeGrip : some View {
Color.orange
.frame(height: thickness)
.gesture(
DragGesture()
.onChanged { gesture in
let screenHeight = UIScreen.main.bounds.size.height
let delta = gesture.translation.height
dragHeight = max(dragHeight - delta, minHeight)
dragHeight = min(screenHeight - thickness - minHeight, dragHeight)
}
)
}
}
I decided to do a more SwiftUI-like approach. It can be any size so it is not fixed to the whole screen size. It can be called like this:
import SwiftUI
import ViewExtractor
struct ContentView: View {
var body: some View {
SeparatedStack(.vertical, ratios: [6, 4]) {
SeparatedStack(.horizontal, ratios: [2, 8]) {
Text("Top left")
Text("Top right")
}
SeparatedStack(.horizontal) {
Text("Bottom left")
Text("Bottom middle")
Text("Bottom right")
}
}
}
}
Result:
Code (read notes below):
// MARK: Extensions
extension Array {
subscript(safe index: Int) -> Element? {
guard indices ~= index else { return nil }
return self[index]
}
}
extension View {
#ViewBuilder func `if`<Output: View>(_ condition: Bool, transform: #escaping (Self) -> Output, else: #escaping (Self) -> Output) -> some View {
if condition {
transform(self)
} else {
`else`(self)
}
}
}
// MARK: Directional layout
enum Axes {
case horizontal
case vertical
}
private struct EitherStack<Content: View>: View {
let axes: Axes
let content: () -> Content
var body: some View {
switch axes {
case .horizontal: HStack(spacing: 0, content: content)
case .vertical: VStack(spacing: 0, content: content)
}
}
}
// MARK: Stacks
struct SeparatedStack: View {
static let dividerWidth: CGFloat = 5
static let minimumWidth: CGFloat = 20
private let axes: Axes
private let ratios: [CGFloat]?
private let views: [AnyView]
init<Views>(_ axes: Axes, ratios: [CGFloat]? = nil, #ViewBuilder content: TupleContent<Views>) {
self.axes = axes
self.ratios = ratios
views = ViewExtractor.getViews(from: content)
}
var body: some View {
GeometryReader { geo in
Color.clear
.overlay(SeparatedStackInternal(views: views, geo: geo, axes: axes, ratios: ratios))
}
}
}
// MARK: Stacks (internal)
private struct SeparatedStackInternal: View {
private struct GapBetween: Equatable {
let gap: CGFloat
let difference: CGFloat?
static func == (lhs: GapBetween, rhs: GapBetween) -> Bool {
lhs.gap == rhs.gap && lhs.difference == rhs.difference
}
}
#State private var dividerProportions: [CGFloat]
#State private var lastProportions: [CGFloat]
private let views: [AnyView]
private let geo: GeometryProxy
private let axes: Axes
init(views: [AnyView], geo: GeometryProxy, axes: Axes, ratios: [CGFloat]?) {
self.views = views
self.geo = geo
self.axes = axes
// Set initial proportions
if let ratios = ratios {
guard ratios.count == views.count else {
fatalError("Mismatching ratios array size. Should be same length as number of views.")
}
let total = ratios.reduce(0, +)
var proportions: [CGFloat] = []
for index in 0 ..< ratios.count - 1 {
let ratioTotal = ratios.prefix(through: index).reduce(0, +)
proportions.append(ratioTotal / total)
}
_dividerProportions = State(initialValue: proportions)
_lastProportions = State(initialValue: proportions)
} else {
let range = 1 ..< views.count
let new = range.map { index in
CGFloat(index) / CGFloat(views.count)
}
_dividerProportions = State(initialValue: new)
_lastProportions = State(initialValue: new)
}
}
var body: some View {
EitherStack(axes: axes) {
ForEach(views.indices) { index in
if index != 0 {
Color.gray
.if(axes == .horizontal) {
$0.frame(width: SeparatedStack.dividerWidth)
} else: {
$0.frame(height: SeparatedStack.dividerWidth)
}
}
let gapAtIndex = gapBetween(index: index)
views[index]
.if(axes == .horizontal) {
$0.frame(maxWidth: gapAtIndex.gap)
} else: {
$0.frame(maxHeight: gapAtIndex.gap)
}
.onChange(of: gapAtIndex) { _ in
if let difference = gapBetween(index: index).difference {
if dividerProportions.indices ~= index - 1 {
dividerProportions[index - 1] -= difference / Self.maxSize(axes: axes, geo: geo)
lastProportions[index - 1] = dividerProportions[index - 1]
}
}
}
}
}
.overlay(overlay(geo: geo))
}
#ViewBuilder private func overlay(geo: GeometryProxy) -> some View {
ZStack {
ForEach(dividerProportions.indices) { index in
Color(white: 0, opacity: 0.0001)
.if(axes == .horizontal) { $0
.frame(width: SeparatedStack.dividerWidth)
.position(x: lastProportions[index] * Self.maxSize(axes: axes, geo: geo))
} else: { $0
.frame(height: SeparatedStack.dividerWidth)
.position(y: lastProportions[index] * Self.maxSize(axes: axes, geo: geo))
}
.gesture(
DragGesture()
.onChanged { drag in
let translation = axes == .horizontal ? drag.translation.width : drag.translation.height
let currentPosition = lastProportions[index] * Self.maxSize(axes: axes, geo: geo) + translation
let offset = SeparatedStack.dividerWidth / 2 + SeparatedStack.minimumWidth
let minPos = highEdge(of: lastProportions, index: index - 1) + offset
let maxPos = lowEdge(of: lastProportions, index: index + 1) - offset
let newPosition = min(max(currentPosition, minPos), maxPos)
dividerProportions[index] = newPosition / Self.maxSize(axes: axes, geo: geo)
}
.onEnded { drag in
lastProportions[index] = dividerProportions[index]
}
)
}
}
.if(axes == .horizontal) {
$0.offset(y: geo.size.height / 2)
} else: {
$0.offset(x: geo.size.width / 2)
}
}
private static func maxSize(axes: Axes, geo: GeometryProxy) -> CGFloat {
switch axes {
case .horizontal: return geo.size.width
case .vertical: return geo.size.height
}
}
private func gapBetween(index: Int) -> GapBetween {
let low = lowEdge(of: dividerProportions, index: index)
let high = highEdge(of: dividerProportions, index: index - 1)
let gap = max(low - high, SeparatedStack.minimumWidth)
let difference = gap == SeparatedStack.minimumWidth ? SeparatedStack.minimumWidth - low + high : nil
return GapBetween(gap: gap, difference: difference)
}
private func lowEdge(of proportions: [CGFloat], index: Int) -> CGFloat {
var edge: CGFloat { proportions[index] * Self.maxSize(axes: axes, geo: geo) - SeparatedStack.dividerWidth / 2 }
return proportions[safe: index] != nil ? edge : Self.maxSize(axes: axes, geo: geo)
}
private func highEdge(of proportions: [CGFloat], index: Int) -> CGFloat {
var edge: CGFloat { proportions[index] * Self.maxSize(axes: axes, geo: geo) + SeparatedStack.dividerWidth / 2 }
return proportions[safe: index] != nil ? edge : 0
}
}
Note: this uses my GeorgeElsham/ViewExtractor for the ability to pass in #ViewBuilder content, rather than just an array of views. This part is not necessary, however I recommend it because it makes the code readable and more SwiftUI-like.
Here is what I have been using. I have a generic SplitView with a primary (P) and secondary (V) view created using ViewBuilders. The fraction identifies the ratio of primary to secondary width or height at open. I use secondaryHidden to force the primary to full width modulo half of the visibleThickness of the Splitter. The invisibleThickness is the grabbable width/height for the Splitter. The SizePreferenceKey is used with a GeometryReader on a clear background to capture the overallSize of the SplitView so that the fraction can be applied properly.
fileprivate struct SplitView<P: View, S: View>: View {
private let layout: Layout
private let zIndex: Double
#Binding var fraction: CGFloat
#Binding var secondaryHidden: Bool
private let primary: P
private let secondary: S
private let visibleThickness: CGFloat = 2
private let invisibleThickness: CGFloat = 30
#State var overallSize: CGSize = .zero
#State var primaryWidth: CGFloat?
#State var primaryHeight: CGFloat?
var hDrag: some Gesture {
// As we drag the Splitter horizontally, adjust the primaryWidth and recalculate fraction
DragGesture()
.onChanged { gesture in
primaryWidth = gesture.location.x
fraction = gesture.location.x / overallSize.width
}
}
var vDrag: some Gesture {
// As we drag the Splitter vertically, adjust the primaryHeight and recalculate fraction
DragGesture()
.onChanged { gesture in
primaryHeight = gesture.location.y
fraction = gesture.location.y / overallSize.height
}
}
enum Layout: CaseIterable {
/// The orientation of the primary and seconday views (e.g., Vertical = VStack, Horizontal = HStack)
case Horizontal
case Vertical
}
var body: some View {
ZStack(alignment: .topLeading) {
switch layout {
case .Horizontal:
// When we init the view, primaryWidth is nil, so we calculate it from the
// fraction that was passed-in. This lets us specify the location of the Splitter
// when we instantiate the SplitView.
let pWidth = primaryWidth ?? width()
let sWidth = overallSize.width - pWidth - visibleThickness
primary
.frame(width: pWidth)
secondary
.frame(width: sWidth)
.offset(x: pWidth + visibleThickness, y: 0)
Splitter(orientation: .Vertical, visibleThickness: visibleThickness)
.frame(width: invisibleThickness, height: overallSize.height)
.position(x: pWidth + visibleThickness / 2, y: overallSize.height / 2)
.zIndex(zIndex)
.gesture(hDrag, including: .all)
case .Vertical:
// When we init the view, primaryHeight is nil, so we calculate it from the
// fraction that was passed-in. This lets us specify the location of the Splitter
// when we instantiate the SplitView.
let pHeight = primaryHeight ?? height()
let sHeight = overallSize.height - pHeight - visibleThickness
primary
.frame(height: pHeight)
secondary
.frame(height: sHeight)
.offset(x: 0, y: pHeight + visibleThickness)
Splitter(orientation: .Horizontal, visibleThickness: visibleThickness)
.frame(width: overallSize.width, height: invisibleThickness)
.position(x: overallSize.width / 2, y: pHeight + visibleThickness / 2)
.zIndex(zIndex)
.gesture(vDrag, including: .all)
}
}
.background(GeometryReader { geometry in
// Track the overallSize using a GeometryReader on the ZStack that contains the
// primary, secondary, and splitter
Color.clear
.preference(key: SizePreferenceKey.self, value: geometry.size)
.onPreferenceChange(SizePreferenceKey.self) {
overallSize = $0
}
})
.contentShape(Rectangle())
}
init(layout: Layout, zIndex: Double = 0, fraction: Binding<CGFloat>, secondaryHidden: Binding<Bool>, #ViewBuilder primary: (()->P), #ViewBuilder secondary: (()->S)) {
self.layout = layout
self.zIndex = zIndex
_fraction = fraction
_primaryWidth = State(initialValue: nil)
_primaryHeight = State(initialValue: nil)
_secondaryHidden = secondaryHidden
self.primary = primary()
self.secondary = secondary()
}
private func width() -> CGFloat {
if secondaryHidden {
return overallSize.width - visibleThickness / 2
} else {
return (overallSize.width * fraction) - (visibleThickness / 2)
}
}
private func height() -> CGFloat {
if secondaryHidden {
return overallSize.height - visibleThickness / 2
} else {
return (overallSize.height * fraction) - (visibleThickness / 2)
}
}
}
fileprivate struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
With the filePrivate SplitView in place, I use HSplitView and VSplitView as the public entry points.
/// A view containing a primary view and a secondary view layed-out vertically and separated by a draggable horizontally-oriented Splitter
///
/// The primary view is above the secondary view.
struct VSplitView<P: View, S: View>: View {
let zIndex: Double
#Binding var fraction: CGFloat
#Binding var secondaryHidden: Bool
let primary: ()->P
let secondary: ()->S
var body: some View {
SplitView(layout: .Vertical, zIndex: zIndex, fraction: $fraction, secondaryHidden: $secondaryHidden, primary: primary, secondary: secondary)
}
init(zIndex: Double = 0, fraction: Binding<CGFloat>, secondaryHidden: Binding<Bool>? = nil, #ViewBuilder primary: #escaping (()->P), #ViewBuilder secondary: #escaping (()->S)) {
self.zIndex = zIndex
_fraction = fraction
_secondaryHidden = secondaryHidden ?? .constant(false)
self.primary = primary
self.secondary = secondary
}
}
/// A view containing a primary view and a secondary view layed-out horizontally and separated by a draggable vertically-oriented Splitter
///
/// The primary view is to the left of the secondary view.
struct HSplitView<P: View, S: View>: View {
let zIndex: Double
#Binding var fraction: CGFloat
#Binding var secondaryHidden: Bool
let primary: ()->P
let secondary: ()->S
var body: some View {
SplitView(layout: .Horizontal, fraction: $fraction, secondaryHidden: $secondaryHidden, primary: primary, secondary: secondary)
}
init(zIndex: Double = 0, fraction: Binding<CGFloat>, secondaryHidden: Binding<Bool>? = nil, #ViewBuilder primary: #escaping (()->P), #ViewBuilder secondary: #escaping (()->S)) {
self.zIndex = zIndex
_fraction = fraction
_secondaryHidden = secondaryHidden ?? .constant(false)
self.primary = primary
self.secondary = secondary
}
}
The Splitter is a ZStack with a visible RoundedRectangle with visibleThickness on top of a clear Color with invisibleThickness.
/// The Splitter that separates the primary from secondary views in a SplitView.
struct Splitter: View {
private let orientation: Orientation
private let color: Color
private let inset: CGFloat
private let visibleThickness: CGFloat
private var invisibleThickness: CGFloat
enum Orientation: CaseIterable {
/// The orientation of the Divider itself.
/// Thus, use Horizontal in a VSplitView and Vertical in an HSplitView
case Horizontal
case Vertical
}
var body: some View {
ZStack(alignment: .center) {
switch orientation {
case .Horizontal:
Color.clear
.frame(height: invisibleThickness)
.padding(0)
RoundedRectangle(cornerRadius: visibleThickness / 2)
.fill(color)
.frame(height: visibleThickness)
.padding(EdgeInsets(top: 0, leading: inset, bottom: 0, trailing: inset))
case .Vertical:
Color.clear
.frame(width: invisibleThickness)
.padding(0)
RoundedRectangle(cornerRadius: visibleThickness / 2)
.fill(color)
.frame(width: visibleThickness)
.padding(EdgeInsets(top: inset, leading: 0, bottom: inset, trailing: 0))
}
}
.contentShape(Rectangle())
}
init(orientation: Orientation, color: Color = .gray, inset: CGFloat = 8, visibleThickness: CGFloat = 2, invisibleThickness: CGFloat = 30) {
self.orientation = orientation
self.color = color
self.inset = inset
self.visibleThickness = visibleThickness
self.invisibleThickness = invisibleThickness
}
}
Here's an example. One additional note is that I had to use zIndex for the Splitter when SplitViews contained other SplitViews that contain other SplitViews. This is because the because the overlap of the multiple Splitters with the primary/secondary of adjacent views prevents the drag gesture from being detected. It's not necessary to specify in simpler cases.
struct ContentView: View {
var body: some View {
HSplitView(
zIndex: 2,
fraction: .constant(0.5),
primary: { Color.red },
secondary: {
VSplitView(
zIndex: 1,
fraction: .constant(0.5),
primary: { Color.blue },
secondary: {
HSplitView(
zIndex: 0,
fraction: .constant(0.5),
primary: { Color.green },
secondary: { Color.yellow }
)
}
)
}
)
}
}
And the result...

How can I access a ForEach loop's index outside of it in another view?

I hope I worded the title correctly. Basically, I'm making a tinder-like interface that will allow the user to swipe through a set of cards. I've followed along with a tutorial to successfully make the swiping through the cards work. However, I can't for the life of me figure out how to get a button to do the same.
Here is an image of the ContentView ---> https://i.imgur.com/K9zN6Vj.png
Here is the main ContentView Code
import SwiftUI
struct ContentView: View {
let cards = Card.data.shuffled()
#State var isHelp = false
var body: some View {
VStack {
ZStack {
AnimatedBackground()
.ignoresSafeArea()
.blur(radius: 25)
VStack {
//Top Stack
Spacer()
TopStack()
//Card
ZStack {
ForEach(cards, id: \.id) { index in
CardView(card: index)
// .shadow(color: .black, radius: 10)
.padding(8)
}
}
//Bottom Stack
BottomStack()
}
}
}
}
}
Here is the CardView code
struct CardView: View {
#State var card: Card
var body: some View {
ZStack {
Color("myBlue")
Text("\(card.text)")
.foregroundColor(.white)
}
.cornerRadius(20)
// Step 1 - ZStack follows the coordinate of the card model
.offset(x: card.x, y: card.y)
.rotationEffect(.init(degrees: card.degree))
// Step 2 - Gesture recognizer updaets the coordinate calues of the card model
.gesture (
DragGesture()
.onChanged { value in
// user is dragging the view
withAnimation(.default) {
card.x = value.translation.width
card.y = value.translation.height
card.degree = 7 * (value.translation.width > 0 ? 1 : -1)
}
}
.onEnded { value in
// do something when the user stops dragging
withAnimation(.interpolatingSpring(mass: 1.0, stiffness: 50, damping: 8, initialVelocity: 0)) {
switch value.translation.width {
case 0...100:
card.x = 0; card.degree = 0; card.y = 0
case let x where x > 100:
card.x = 500; card.degree = 12
case (-100)...(-1):
card.x = 0; card.degree = 0; card.y = 0;
case let x where x < -100:
card.x = -500; card.degree = -12
default: card.x = 0; card.y = 0
}
}
}
)
}
}
The bottom stack is just buttons. I want the buttons to essentially go forward and backward through the index, but I don't know how to access it because it's in a different view. Again, I'm not entirely sure how the main swiping is working; I followed along with a tutorial so I'm definitely out of my comfort zone. Maybe a button wouldn't work at all with the way the swiping is being achieved? Any help is super appreciated, thank you for taking a look!

SwiftUI Animated Shape going crazy

First off, my appologies for the unconventional title of this post, but I don't know of any way to describe the behavior any better than that.
To reproduce this problem, create a new project in xCode 12 specifying IOS App with any name and **Interface:**SwiftUI, Life Cycle: SwiftUI App, Language: Swift. Then replace all of Content View with the code listed here.
In either Live Preview, or when running the app, a click on the Poly will trigger the animation. It should move a fraction of a pixel, and do a 1/3 turn (as the angle is in radians).
The problem, as noted on line 37 is that when I try to move or turn the Poly, it goes crazy, multiplying any move by much greater amounts. The color animates fine, but the Animatable properties in the shape do not. The location starts at 200,200 with an angle of 0, and if you try to move it very close, as the sample code does, it overreacts. If you try to move it only a few pixes (say 190,190) it will fly off the screen.
I have not had this happen to any other animations I have done, and have no idea why this is behaving this way.
In trying to debug this, at one point I put print statements on the animatableData getters and setters, and can make no sense of what the animation engine is doing to the variables. It just seems to pick a number that is much further from the source that the value I am asking it to go to.
I am confident that the trig in the path is correct, and suspect the issue lies in one of the following:
My declaration of animatableData somehow
The withAnimation function in the gesture
An issue with animating a CGFloat
SwiftUI is just going crazy
I am running Xcode 12.1 (12A7403) and Swift 5. After many hours of trying to figure this out, I humbly present my problem here. Help me Obiwan Kenobi, you are my only hope...
import SwiftUI
let twoPi:CGFloat = CGFloat.pi * 2
let pi = CGFloat.pi
class Poly: ObservableObject, Identifiable {
#Published var location:CGPoint
#Published var color:Color
var sides:CGFloat
var vertexRadius:CGFloat
var angle:CGFloat
init(at:CGPoint, color:Color, sides:CGFloat, radius:CGFloat, angle:CGFloat=0) {
self.location = at
self.color = color
self.sides = sides
self.vertexRadius = radius
self.angle = angle
}
}
struct ContentView: View {
#ObservedObject var poly:Poly = Poly(at: CGPoint(x:200,y:200), color: .green, sides: 6, radius: 100)
var body: some View {
PolyShape(poly: poly)
.fill(poly.color)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded { gesture in
withAnimation(.easeInOut(duration:15)) {
//This is what doesn't work.
//Try to nudge it a fraction of a pixel, and do only 1/3 of a turn, and it spins and moves much further.
poly.location.x = 200.4
poly.location.y = 200.2
poly.angle = twoPi / 3
poly.color = .red
}
}
)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView(poly: Poly(at: CGPoint(x:200,y:200), color: .blue, sides: 3, radius: 100))
}
}
}
struct PolyShape:Shape {
var poly:Poly
public var animatableData: AnimatablePair<CGFloat, AnimatablePair<CGFloat,CGFloat>> {
get { AnimatablePair(poly.angle, AnimatablePair(poly.location.x, poly.location.y))
}
set {
poly.angle = newValue.first
poly.location.x = newValue.second.first
poly.location.y = newValue.second.second
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
var radial:CGFloat = 0.5
while radial < twoPi + 0.5 {
let radialAngle = twoPi / poly.sides * radial + poly.angle
let newX = poly.location.x + cos(radialAngle) * poly.vertexRadius
let newY = poly.location.y + sin(radialAngle) * poly.vertexRadius
if radial == 0.5 {
path.move(to: CGPoint(x: newX, y: newY))
} else {
path.addLine(to: CGPoint(x: newX, y: newY))
}
radial += 1
}
return path
}
}
You need to separate model from view model, because to have PolyShape correctly work in your case the input data have to be a value.
Here is tested solution (Xcode 12 / iOS 14)
Separate model and view model
class Poly: ObservableObject, Identifiable {
#Published var data:PolyData
init(data: PolyData) {
self.data = data
}
}
struct PolyData {
var location:CGPoint
var color:Color
var sides:CGFloat
var vertexRadius:CGFloat
var angle:CGFloat
init(at:CGPoint, color:Color, sides:CGFloat, radius:CGFloat, angle:CGFloat=0) {
self.location = at
self.color = color
self.sides = sides
self.vertexRadius = radius
self.angle = angle
}
}
Make shape value dependent
struct PolyShape:Shape {
var poly:PolyData // << here !!
// ... other code no changes
}
Update dependent demo
struct ContentView: View {
#ObservedObject var poly:Poly = Poly(data: PolyData(at: CGPoint(x:200,y:200), color: .green, sides: 6, radius: 100))
var body: some View {
PolyShape(poly: poly.data) // << pass data only here !!
.fill(poly.data.color)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded { gesture in
withAnimation(.easeInOut(duration:15)) {
poly.data.location = CGPoint(x: 200.4, y: 200.2)
poly.data.angle = twoPi / 3
poly.data.color = .red
}
}
)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView(poly: Poly(data: PolyData(at: CGPoint(x:200,y:200), color: .blue, sides: 3, radius: 100)))
}
}
}
Can you give the following a try?
import SwiftUI
let twoPi:CGFloat = CGFloat.pi * 2
let pi = CGFloat.pi
struct ContentView: View {
#State var location: CGPoint = CGPoint(x: 200, y: 200)
#State var color: Color = .blue
#State var angle: CGFloat = 0
var body: some View {
PolyShape(location: location, color: color, angle: angle, sides: 3, vertexRadius: 100)
.fill(color)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded { gesture in
withAnimation(.easeInOut(duration:1)) {
//This is what doesn't work.
//Try to nudge it a fraction of a pixel, and do only 1/3 of a turn, and it spins and moves much further.
location.x = 220
location.y = 220
angle = (CGFloat.pi * 2) / 3
color = .red
}
}
)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView(location: CGPoint(x: 200, y: 200), color: .blue, angle: 0)
}
}
}
struct PolyShape:Shape {
var location: CGPoint
var color: Color
var angle: CGFloat
var sides: CGFloat
var vertexRadius: CGFloat
public var animatableData: AnimatablePair<CGFloat, AnimatablePair<CGFloat,CGFloat>> {
get {
return AnimatablePair(angle, AnimatablePair(location.x, location.y))
}
set {
angle = newValue.first
location.x = newValue.second.first
location.y = newValue.second.second
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
var radial:CGFloat = 0.5
while radial < twoPi + 0.5 {
let radialAngle = twoPi / sides * radial + angle
let newX = location.x + cos(radialAngle) * vertexRadius
let newY = location.y + sin(radialAngle) * vertexRadius
if radial == 0.5 {
path.move(to: CGPoint(x: newX, y: newY))
} else {
path.addLine(to: CGPoint(x: newX, y: newY))
}
radial += 1
}
return path
}
}

Resources