Extra unneeded rendering issue with Custom Grid View in SwiftUI - swift

I am making a CustomGridView without using Apple Grid, my codes works fine but it has an extra rendering issue when I add new row! As long as I see in my codes, if I add new row, Xcode should render just for new Items! But in the fact it renders all the items! which I can not find the issue! I am okay if Xcode renders all Items if I add new column, because the size put effect on every single Item, but with adding row, there is no general effect on items!
import SwiftUI
struct GridItemView: View {
let rowItem: GridItemRowType
let columnItem: GridItemColumnType
let string: String
let size: CGFloat
#State private var color: Color = Color.randomColor
init(rowItem: GridItemRowType, columnItem: GridItemColumnType, size: CGFloat) {
self.rowItem = rowItem
self.columnItem = columnItem
self.size = size
self.string = "(" + String(describing: rowItem.index) + "," + String(describing: columnItem.index) + ")"
}
var body: some View {
print("rendering for:", string)
return RoundedRectangle(cornerRadius: 10.0)
.fill(color)
.padding(5.0)
.frame(width: size, height: size)
.overlay(
Text(string)
.bold()
.foregroundColor(Color.white)
.shadow(color: .black, radius: 2, x: 0.0, y: 0.0)
)
.onTapGesture {
color = Color.white
}
.shadow(radius: 20.0)
}
}
extension Color {
static var randomColor: Color {
get { return Color(red: Double.random(in: 0...1), green: Double.random(in: 0...1), blue: Double.random(in: 0...1)) }
}
}
struct CustomGridView: View {
let gridItemColumn: [GridItemColumnType]
let gridItemRow: [GridItemRowType]
var body: some View {
GeometryReader { proxy in
let size: CGFloat = (proxy.size.width - 5.0)/CGFloat(gridItemColumn.count)
ScrollView {
VStack(spacing: .zero) {
ForEach(gridItemRow) { rowItem in
HStack(spacing: .zero) {
ForEach(gridItemColumn) { columnItem in
GridItemView(rowItem: rowItem, columnItem: columnItem, size: size)
}
}
.transition(AnyTransition.asymmetric(insertion: AnyTransition.move(edge: .top), removal: AnyTransition.move(edge: .top)))
}
Spacer()
}
.transition(AnyTransition.asymmetric(insertion: AnyTransition.move(edge: .trailing), removal: AnyTransition.move(edge: .trailing)))
}
.position(x: proxy.size.width/2.0, y: proxy.size.height/2.0)
}
.animation(Animation.default, value: [gridItemRow.count, gridItemColumn.count])
}
}
struct GridItemColumnType: Identifiable {
let id: UUID = UUID()
var index: Int
static let initializingGridItemColumn: [GridItemColumnType] = [GridItemColumnType(index: 0),
GridItemColumnType(index: 1),
GridItemColumnType(index: 2),
GridItemColumnType(index: 3),
GridItemColumnType(index: 4)]
}
struct GridItemRowType: Identifiable {
let id: UUID = UUID()
var index: Int
static let initializingGridItemRowType: [GridItemRowType] = [GridItemRowType(index: 0),
GridItemRowType(index: 1),
GridItemRowType(index: 2)]
}
struct ContentView: View {
#State private var gridItemColumn: [GridItemColumnType] = GridItemColumnType.initializingGridItemColumn
#State private var gridItemRow: [GridItemRowType] = GridItemRowType.initializingGridItemRowType
var body: some View {
CustomGridView(gridItemColumn: gridItemColumn, gridItemRow: gridItemRow)
Spacer()
Button("add Column") { gridItemColumn.append(GridItemColumnType(index: gridItemColumn.count)) }
Button("add Row") { gridItemRow.append(GridItemRowType(index: gridItemRow.count)) }
}
}

You need to make cell view equatable, so SwiftUI would know if it should be re-rendered.
Tested with Xcode 13.2 / iOS 15.2
struct GridItemView: View, Equatable {
// The `string` property is used just for demo, in real you
// need some unique identifier which represents that view
// has same content
static func == (lhs: GridItemView, rhs: GridItemView) -> Bool {
lhs.string == rhs.string
}
// ... other code
}
and then inside grid use it as
ForEach(gridItemColumn) { columnItem in
EquatableView(content: GridItemView(rowItem: rowItem,
columnItem: columnItem, size: size))
}

Related

Slide Carousel cards on Cards tap in SwiftUI

I have created a carousel cards in SwiftUI, it is working on the DragGesture
I want to achieve same experience on the tap of cards i.e. on .onTapGesture, which ever cards is being tapped it should be slide to centre on the screen like shown in the video attached
My current code -
import SwiftUI
struct Item: Identifiable {
var id: Int
var title: String
var color: Color
}
class Store: ObservableObject {
#Published var items: [Item]
let colors: [Color] = [.red, .orange, .blue, .teal, .mint, .green, .gray, .indigo, .black]
// dummy data
init() {
items = []
for i in 0...7 {
let new = Item(id: i, title: "Item \(i)", color: colors[i])
items.append(new)
}
}
}
struct ContentView: View {
#StateObject var store = Store()
#State private var snappedItem = 0.0
#State private var draggingItem = 0.0
#State var activeIndex: Int = 0
var body: some View {
ZStack {
ForEach(store.items) { item in
// article view
ZStack {
RoundedRectangle(cornerRadius: 18)
.fill(item.color)
Text(item.title)
.padding()
}
.frame(width: 200, height: 200)
.scaleEffect(1.0 - abs(distance(item.id)) * 0.2 )
.opacity(1.0 - abs(distance(item.id)) * 0.3 )
.offset(x: myXOffset(item.id), y: 0)
.zIndex(1.0 - abs(distance(item.id)) * 0.1)
}
}
.gesture(getDragGesture())
.onTapGesture {
//move card to centre
}
}
private func getDragGesture() -> some Gesture {
DragGesture()
.onChanged { value in
draggingItem = snappedItem + value.translation.width / 100
}
.onEnded { value in
withAnimation {
draggingItem = snappedItem + value.predictedEndTranslation.width / 100
draggingItem = round(draggingItem).remainder(dividingBy: Double(store.items.count))
snappedItem = draggingItem
//Get the active Item index
self.activeIndex = store.items.count + Int(draggingItem)
if self.activeIndex > store.items.count || Int(draggingItem) >= 0 {
self.activeIndex = Int(draggingItem)
}
}
}
}
func distance(_ item: Int) -> Double {
return (draggingItem - Double(item)).remainder(dividingBy: Double(store.items.count))
}
func myXOffset(_ item: Int) -> Double {
let angle = Double.pi * 2 / Double(store.items.count) * distance(item)
return sin(angle) * 200
}
}
You need to apply the .onTapGesture() modifier to the single item inside the ForEach, not around it.
Then, you just need to handle the different cases, comparing the tapped item with the one currently on the front, and change the value of draggingItem accordingly.
Here's the code inside the view's body:
ZStack {
ForEach(store.items) { item in
// article view
ZStack {
RoundedRectangle(cornerRadius: 18)
.fill(item.color)
Text(item.title)
.padding()
}
.frame(width: 200, height: 200)
.scaleEffect(1.0 - abs(distance(item.id)) * 0.2 )
.opacity(1.0 - abs(distance(item.id)) * 0.3 )
.offset(x: myXOffset(item.id), y: 0)
.zIndex(1.0 - abs(distance(item.id)) * 0.1)
// Here is the modifier - on the item, not on the ForEach
.onTapGesture {
// withAnimation is necessary
withAnimation {
draggingItem = Double(item.id)
}
}
}
}
.gesture(getDragGesture())

Share background across views

I have two issues that would love some help on. I have a landing page, split up between two views. One view shows an image, and another displays two buttons (login and sign up). When navigating to the buttons, I would like to stay on the view and not navigate fully to a new view.
I would also like the background to remain the same as we navigate. I attached a gif of the desired effect (minus the background).
In the code below, I added a ZStack which adds a background modifier, but navigating goes fully to a new view, not remaining on the same page.
extension View {
func animatableGradient(fromGradient: Gradient, toGradient: Gradient, progress: CGFloat) -> some View {
self.modifier(AnimatableGradientModifier(fromGradient: fromGradient, toGradient: toGradient, progress: progress))
}
}
struct AnimatableGradientModifier: AnimatableModifier {
let fromGradient: Gradient
let toGradient: Gradient
var progress: CGFloat = 0.0 //keeps track of gradient change
var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
var gradientColors = [Color]()
for i in 0..<fromGradient.stops.count {
let fromColor = UIColor(fromGradient.stops[i].color)
let toColor = UIColor(toGradient.stops[i].color)
gradientColors.append(colorMixer(fromColor: fromColor, toColor: toColor, progress: progress))
}
return LinearGradient(gradient: Gradient(colors: gradientColors), startPoint: .topLeading, endPoint: .bottomTrailing)
}
func colorMixer(fromColor: UIColor, toColor: UIColor, progress: CGFloat) -> Color {
guard let fromColor = fromColor.cgColor.components else { return Color(fromColor) }
guard let toColor = toColor.cgColor.components else { return Color(toColor) }
let red = fromColor[0] + (toColor[0] - fromColor[0]) * progress
let green = fromColor[1] + (toColor[1] - fromColor[1]) * progress
let blue = fromColor[2] + (toColor[2] - fromColor[2]) * progress
return Color(red: Double(red), green: Double(green), blue: Double(blue))
}
}
struct LandingPage: View {
#AppStorage("signedIn") var signedIn = false
#State private var isPressed = false
#Environment (\.dismiss) var dismiss
#State private var progress: CGFloat = 0
//colors for background on landing page
let gradient1 = Gradient(colors: [.yellow, .orange])
let gradient2 = Gradient(colors: [.yellow, .pink])
#State private var animateGradient = false
#State var scale: CGFloat = 1.0
#State var offsetValue: CGFloat = -60 // << image
#State var isMealJournalTitleShowing = false
#ViewBuilder
var body: some View {
NavigationView{
ZStack{
Rectangle()
.animatableGradient(fromGradient: gradient1, toGradient: gradient2, progress: progress)
.ignoresSafeArea()
VStack{
test()
VStack{
NavigationLink(destination: testb() .navigationBarHidden(true),
label:{
Text("Get Started").fontWeight(.bold)
.frame(minWidth: 0, maxWidth: 200)
.padding(10)
.foregroundColor(.white)
//draw rectange around buttons
.background(
RoundedRectangle(cornerRadius: 20)
.fill(
LinearGradient(
colors: [.orange, .yellow],
startPoint: .topLeading,
endPoint: .bottomTrailing
)))
})
.simultaneousGesture(TapGesture().onEnded{
offsetValue = -125
scale -= 0.50
isMealJournalTitleShowing = true
})
NavigationLink(destination: testb().navigationBarHidden(true), label: {
Text("Login").fontWeight(.semibold)
.frame(minWidth:0, maxWidth: 200)
.padding(10)
.foregroundColor(.black)
.overlay( RoundedRectangle(cornerRadius: 25)
.stroke(Color.gray, lineWidth: 3)
)
})
}
.frame(height: 500)
}
.frame(height: 500)
}
.onAppear {
DispatchQueue.main.async {
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: true)) {
self.progress = 1.0
}
}
}
.animation(.easeInOut(duration: 0.50), value: offsetValue)
}
}
}
Navigating creates and shows a complete new view, that's why the background changes, of course. Instead of navigating, maybe you could use something like this?
You could play around with transitions like .transition(.slide.combined(with: .opacity)) for example to get the effect you want.
To get the image animating and scaling, you could just lookup .matchedGeometryEffect.
import SwiftUI
enum ViewState {
case landingPage
case login
case register
case isLoggedIn
}
struct ContentView: View {
#State private var viewState: ViewState = .landingPage
var body: some View {
ZStack {
Color.teal.opacity(0.4)
switch viewState {
case .landingPage:
VStack {
Text("LandingPage")
.transition(.slide)
.padding(50)
Button("Login") {
viewState = .login
}
Button("Register") {
viewState = .register
}
}
case .login:
Text("Login")
.transition(.slide)
case .register:
Text("Register")
.transition(.slide)
case .isLoggedIn:
Text("LoggedIn")
.transition(.slide)
}
}
.animation(.default, value: viewState)
}
}

Custom Segmented Controller SwiftUI Frame Issue

I would like to create a custom segmented controller in SwiftUI, and I found one made from this post. After slightly altering the code and putting it into my ContentView, the colored capsule would not fit correctly.
Here is an example of my desired result:
This is the result when I use it in ContentView:
CustomPicker.swift:
struct CustomPicker: View {
#State var selectedIndex = 0
var titles = ["Item #1", "Item #2", "Item #3", "Item #4"]
private var colors = [Color.red, Color.green, Color.blue, Color.purple]
#State private var frames = Array<CGRect>(repeating: .zero, count: 4)
var body: some View {
VStack {
ZStack {
HStack(spacing: 4) {
ForEach(self.titles.indices, id: \.self) { index in
Button(action: { self.selectedIndex = index }) {
Text(self.titles[index])
.foregroundColor(.black)
.font(.system(size: 16, weight: .medium, design: .default))
.bold()
}.padding(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)).background(
GeometryReader { geo in
Color.clear.onAppear { self.setFrame(index: index, frame: geo.frame(in: .global)) }
}
)
}
}
.background(
Capsule().fill(
self.colors[self.selectedIndex].opacity(0.4))
.frame(width: self.frames[self.selectedIndex].width,
height: self.frames[self.selectedIndex].height, alignment: .topLeading)
.offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX)
, alignment: .leading
)
}
.animation(.default)
.background(Capsule().stroke(Color.gray, lineWidth: 3))
}
}
func setFrame(index: Int, frame: CGRect) {
self.frames[index] = frame
}
}
ContentView.swift:
struct ContentView: View {
#State var itemsList = [Item]()
func loadData() {
if let url = Bundle.main.url(forResource: "Data", withExtension: "json") {
do {
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
let jsonData = try decoder.decode(Response.self, from: data)
for post in jsonData.content {
self.itemsList.append(post)
}
} catch {
print("error:\(error)")
}
}
}
var body: some View {
NavigationView {
VStack {
Text("Item picker")
.font(.system(.title))
.bold()
CustomPicker()
Spacer()
ScrollView {
VStack {
ForEach(itemsList) { item in
ItemView(text: item.text, username: item.username)
.padding(.leading)
}
}
}
.frame(height: UIScreen.screenHeight - 224)
}
.onAppear(perform: loadData)
}
}
}
Project file here
The problem with the code as-written is that the GeometryReader value is only sent on onAppear. That means that if any of the views around it change and the view is re-rendered (like when the data is loaded), those frames will be out-of-date.
I solved this by using a PreferenceKey instead, which will run on each render:
struct CustomPicker: View {
#State var selectedIndex = 0
var titles = ["Item #1", "Item #2", "Item #3", "Item #4"]
private var colors = [Color.red, Color.green, Color.blue, Color.purple]
#State private var frames = Array<CGRect>(repeating: .zero, count: 4)
var body: some View {
VStack {
ZStack {
HStack(spacing: 4) {
ForEach(self.titles.indices, id: \.self) { index in
Button(action: { self.selectedIndex = index }) {
Text(self.titles[index])
.foregroundColor(.black)
.font(.system(size: 16, weight: .medium, design: .default))
.bold()
}
.padding(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16))
.measure() // <-- Here
.onPreferenceChange(FrameKey.self, perform: { value in
self.setFrame(index: index, frame: value) //<-- this will run each time the preference value changes, will will happen any time the frame is updated
})
}
}
.background(
Capsule().fill(
self.colors[self.selectedIndex].opacity(0.4))
.frame(width: self.frames[self.selectedIndex].width,
height: self.frames[self.selectedIndex].height, alignment: .topLeading)
.offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX)
, alignment: .leading
)
}
.animation(.default)
.background(Capsule().stroke(Color.gray, lineWidth: 3))
}
}
func setFrame(index: Int, frame: CGRect) {
print("Setting frame: \(index): \(frame)")
self.frames[index] = frame
}
}
struct FrameKey : PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
extension View {
func measure() -> some View {
self.background(GeometryReader { geometry in
Color.clear
.preference(key: FrameKey.self, value: geometry.frame(in: .global))
})
}
}
Note that the original .background call was taken out and was replaced with .measure() and .onPreferenceChange -- look for where the //<-- Here note is.
Besides that and the PreferenceKey and View extension, nothing else is changed.

Swing passing a #State var as argument does not update view

I am following the Apple Developer Tutorial for SwingUI and I am pretty new. I am working on animations currently and I am confused about #State variables. Let's look at the following code:
import SwiftUI
struct HikeDetail: View {
let hike: Hike
#State var dataToShow = \Hike.Observation.elevation
var buttons = [
("Elevation", \Hike.Observation.elevation),
("Heart Rate", \Hike.Observation.heartRate),
("Pace", \Hike.Observation.pace),
]
var body: some View {
VStack {
HikeGraph(hike: hike, path: self.dataToShow)
.frame(height: 200)
HStack(spacing: 25) {
ForEach(buttons, id: \.0) { value in
Button(action: {
self.dataToShow = value.1
}) {
Text(value.0)
.font(.system(size: 15))
.foregroundColor(value.1 == self.dataToShow
? Color.green
: Color.accentColor)
.animation(nil)
}
}
}
}
}
}
HikeGraph is another SwiftUI View that produces a graph, it has three options:
- elevation => gray color
- heartRate => red color
- pace => purple color
Each of them has a different color and shape, however when I change between these three options, I only see one type of graph which is the initially defined one.
#State var dataToShow = \Hike.Observation.elevation
When I change the path variable of:
HikeGraph(hike: hike, path: dataToShow)
.frame(height: 200)
The graph does not change. However, when I change dataToShow variable by myself, I can observe the color change.
My question is how can I update the graph itself when the state variable is changed.
Edit: HikeGraph View
import SwiftUI
func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
where C.Element == Range<Double> {
guard !ranges.isEmpty else { return 0..<0 }
let low = ranges.lazy.map { $0.lowerBound }.min()!
let high = ranges.lazy.map { $0.upperBound }.max()!
return low..<high
}
func magnitude(of range: Range<Double>) -> Double {
return range.upperBound - range.lowerBound
}
extension Animation {
static func ripple(index: Int) -> Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
.delay(0.03 * Double(index))
}
}
struct HikeGraph: View {
var hike: Hike
var path: KeyPath<Hike.Observation, Range<Double>>
var color: Color {
switch path {
case \.elevation:
return .gray
case \.heartRate:
return Color(hue: 0, saturation: 0.5, brightness: 0.7)
case \.pace:
return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
default:
return .black
}
}
var body: some View {
let data = hike.observations
let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: self.path] })
let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
let heightRatio = (1 - CGFloat(maxMagnitude / magnitude(of: overallRange))) / 2
return GeometryReader { proxy in
HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
ForEach(data.indices) { index in
GraphCapsule(
index: index,
height: proxy.size.height,
range: data[index][keyPath: self.path],
overallRange: overallRange)
.colorMultiply(self.color)
.transition(.slide)
.animation(.ripple(index: index))
}
.offset(x: 0, y: proxy.size.height * heightRatio)
}
}
}
}

SwiftUI list animations

I am following Apple's Swift UI Animating Views And Transitions and I noticed a bug in the Hike Graph View. When I click on the graph it does not allow me to switch from Elevation to Heart Rate or Pace. It does not let me and just exits the view. I think this has something to do with the List here:
VStack(alignment: .leading) {
Text("Recent Hikes")
.font(.headline)
HikeView(hike: hikeData[0])
}
Hike View Contains:
import SwiftUI
struct HikeView: View {
var hike: Hike
#State private var showDetail = false
var transition: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
.animation(nil)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}
if showDetail {
HikeDetail(hike: hike)
.transition(transition)
}
}
}
}
Hike Detail Contains:
struct HikeDetail: View {
let hike: Hike
#State var dataToShow = \Hike.Observation.elevation
var buttons = [
("Elevation", \Hike.Observation.elevation),
("Heart Rate", \Hike.Observation.heartRate),
("Pace", \Hike.Observation.pace),
]
var body: some View {
return VStack {
HikeGraph(hike: hike, path: dataToShow)
.frame(height: 200)
HStack(spacing: 25) {
ForEach(buttons, id: \.0) { value in
Button(action: {
self.dataToShow = value.1
}) {
Text(value.0)
.font(.system(size: 15))
.foregroundColor(value.1 == self.dataToShow
? Color.gray
: Color.accentColor)
.animation(nil)
}
}
}
}
}
}
Hike Graoh Contains:
import SwiftUI
func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
where C.Element == Range<Double> {
guard !ranges.isEmpty else { return 0..<0 }
let low = ranges.lazy.map { $0.lowerBound }.min()!
let high = ranges.lazy.map { $0.upperBound }.max()!
return low..<high
}
func magnitude(of range: Range<Double>) -> Double {
return range.upperBound - range.lowerBound
}
extension Animation {
static func ripple(index: Int) -> Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
.delay(0.03 * Double(index))
}
}
struct HikeGraph: View {
var hike: Hike
var path: KeyPath<Hike.Observation, Range<Double>>
var color: Color {
switch path {
case \.elevation:
return .gray
case \.heartRate:
return Color(hue: 0, saturation: 0.5, brightness: 0.7)
case \.pace:
return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
default:
return .black
}
}
var body: some View {
let data = hike.observations
let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: self.path] })
let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
let heightRatio = (1 - CGFloat(maxMagnitude / magnitude(of: overallRange))) / 2
return GeometryReader { proxy in
HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
ForEach(data.indices) { index in
GraphCapsule(
index: index,
height: proxy.size.height,
range: data[index][keyPath: self.path],
overallRange: overallRange)
.colorMultiply(self.color)
.transition(.slide)
.animation(.ripple(index: index))
}
.offset(x: 0, y: proxy.size.height * heightRatio)
}
}
}
}
Graph Capsule Contains:
import SwiftUI
struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>
var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}
var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}
var body: some View {
Capsule()
.fill(Color.white)
.frame(height: height * heightRatio)
.offset(x: 0, y: height * -offsetRatio)
}
}
Is there any way to fix this? Thanks
The problem might be deeper in SwiftUI - if you comment out transition(.slide) in HikeGraph (and restart the XCODE), it will start working