How to make SwiftUI to forget internal state - swiftui

I have a view like following
struct A: View {
var content: AnyView
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack {
// Common Elements
content
// More Common Elements
}
}
}
}
When I call this from another view like
A(nextInnerView())
two things happen. Firstly, as the size of the content element changes ScrollView animates the transition. Secondly, if you scroll down and then change the content the scrolling position does not reset.

Here is a demo of possible solution. Tested with Xcode 11.4 / iOS 13.4
The origin of this behaviour is in SwiftUI rendering optimisation, that tries to re-render only changed part, so approach is to identify view A (to mark it as completely changed) based on condition that originated in interview changes, alternatively it can be identified just by UUID().
struct TestInnerViewReplacement: View {
#State private var counter = 0
var body: some View {
VStack {
Button("Next") { self.counter += 1 }
Divider()
A(content: nextInnerView())
.id(counter) // << here !!
}
}
private func nextInnerView() -> AnyView {
AnyView(Group {
if counter % 2 == 0 {
Text("Text Demo")
} else {
Image(systemName: "star")
.resizable()
.frame(width: 100, height: 100)
}
})
}
}
struct A: View {
var content: AnyView
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack {
ForEach(0..<5) { _ in // upper content demo
Rectangle().fill(Color.yellow)
.frame(height: 40)
.frame(maxWidth: .infinity)
.padding()
}
content
ForEach(0..<10) { _ in // lower content demo
Rectangle().fill(Color.blue)
.frame(height: 40)
.frame(maxWidth: .infinity)
.padding()
}
}
}
}
}

Related

SwiftUI Animate Bar Between Custom Segment Control

I have designs for a custom Tab component (Segmented Control). The implementation is pretty basic, but one of the design requirements is for the bar at the bottom to animate between the different options (move on x axis + grow to new text size).
I have the below (WIP) implementation that statically swaps the items, but I am not sure how to get the animation between the items.
Using overlay allows for the bar to dynamically take up the full width of the parent, but I wonder if there needs to be a seperate bar that animates between the items.
Here is the WIP code:
struct Tabs: View {
#Binding var selectedTab: Int
var tabs: [Tab]
init(_ selectedTab: Binding<Int>, tabs: [Tab]) {
self._selectedTab = selectedTab
self.tabs = tabs
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(self.tabs.indices) { tabIndex in
let tab = self.tabs[tabIndex]
Button(action: {
withAnimation {
self.selectedTab = tabIndex
}
}) {
Text(tab.title)
.font(.body.weight(.medium))
.foregroundColor(.blue)
.padding(.bottom, 8)
.padding(.top, 2)
.padding(.horizontal, 4)
.if(tabIndex == self.selectedTab) {
$0.overlay(
Rectangle()
.fill(Color.blue)
.frame(width: .infinity, height: 3),
alignment: .bottom
)
}
}
}
}
}
}
}
and here is the expected design (note the underline bar, that is what I need to animate).
You create a new view under each tab during selection, this will not work. For SwiftUI, these will be different views, so they won't animate the position change.
Instead, I suggest you read this great article about alignment guides, especially the Cross Stack Alignment part.
So, using alignment guides, we can bind one of the view guides, such as center, to the selected center of the tab.
But we also need to get the width somehow. I do this with GeometryReader.
struct Tabs: View {
#State var selectedTab = 0
var tabs: [Tab]
#State private var tabWidths = [Int: CGFloat]()
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .crossAlignment, spacing: 0) {
HStack(spacing: 12) {
ForEach(self.tabs.indices) { tabIndex in
let tab = self.tabs[tabIndex]
Button(action: {
withAnimation {
self.selectedTab = tabIndex
}
}) {
Text(tab.title)
.font(.body.weight(.medium))
.foregroundColor(.blue)
.padding(.bottom, 8)
.padding(.top, 2)
.padding(.horizontal, 4)
.if(tabIndex == self.selectedTab) {
$0.alignmentGuide(.crossAlignment) { d in
d[HorizontalAlignment.center]
}
}
}.sizeReader { size in
tabWidths[tabIndex] = size.width
}
}
}
Rectangle()
.fill(Color.blue)
.frame(width: tabWidths[selectedTab], height: 3)
.alignmentGuide(.crossAlignment) { d in
d[HorizontalAlignment.center]
}
}
}
}
}
extension View {
func sizeReader(_ block: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometry -> Color in
DispatchQueue.main.async { // to avoid warning
block(geometry.size)
}
return Color.clear
}
)
}
}
extension HorizontalAlignment {
private enum CrossAlignment: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
d[HorizontalAlignment.center]
}
}
static let crossAlignment = HorizontalAlignment(CrossAlignment.self)
}
p.s. Don't use .frame(width: .infinity) to extend the view, use .frame(maxWidth: .infinity) instead. Yes, you must split it into two modifiers if you want to provide a static height.
p.s.s. You should use if modifier very carefully. It's fine in this case, but in most cases it will break your animation, see this article to understand why.

Crash due index out of range -- although I'm sure index is not out of range

I have a parent view whose child view is any given index of an array. The index of the array is scrolled through by tapping buttons that increment or decrement the index which is stored in a State property.
However when the view is first initialized I get a crash, even though the State's initial value is always 0.
What is going on?
Code can be copied and pasted to reproduce error
import SwiftUI
struct ContentView: View {
#State private var shouldShowQuotes = false
var body: some View {
ZStack {
Color.orange
VStack {
Button(action: showQuotes){
Text("Get Quotes").bold()
.frame(maxWidth: 300)
}
// .controlProminence(.increased) //Safe to uncomment if Xcode 13
// .buttonStyle(.bordered)
// .controlSize(.large)
}
.fullScreenCover(isPresented: $shouldShowQuotes) {
QuoteScreen()
}
}.ignoresSafeArea()
}
private func showQuotes() {
self.shouldShowQuotes.toggle()
}
}
struct QuoteScreen: View {
#State private var quoteIndex = 0
var currentQuote: Quote {
return dummyData[quoteIndex]
}
var body: some View {
ZStack {
Color.orange
VStack {
QuoteView(quote: currentQuote)
Spacer()
HStack {
Button(action: degress) {
Image(systemName: "arrow.left.square.fill")
.resizable()
.frame(width: 50, height: 50)
}
Spacer()
Button(action: progress) {
Image(systemName: "arrow.right.square.fill")
.resizable()
.frame(width: 50, height: 50)
}
}
.padding(28)
//.buttonStyle(.plain) Safe to uncomment if Xcode 13
}
}.ignoresSafeArea()
}
private func progress() {
quoteIndex += 1
}
private func degress() {
quoteIndex -= 1
}
}
struct QuoteView: View {
#State private var showQuotes = false
let quote: Quote
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.stroke(lineWidth: 2)
VStack {
Text(quote.quote)
frame(maxWidth: 300)
Text(quote.author)
.frame(maxWidth: 300, alignment: .trailing)
.foregroundColor(.secondary)
}
}.navigationBarHidden(true)
.frame(height: 400)
.padding()
}
}
let dummyData = [Quote(quote: "The apple does not fall far from the tree", author: "Lincoln", index: 1),
Quote(quote: "Not everything that can be faced can be changed, but be sure that nothing can change until it is faced", author: "Unknown", index: 2),
Quote(quote: "Actions are but intentions", author: "Muhammad", index: 3)
]
struct Quote: Codable {
let quote: String
let author: String
let index: Int
}
When using arrays you always have to check that the element at the chosen index exist. This is how
I tested and modify your code to make it work.
(note: although this is just a test with dummyData, you need to decide if you want to scroll through the array index, or the Quote-index value, and adjust accordingly)
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
let dummyData = [
Quote(quote: "the index zero quote", author: "silly-billy", index: 0),
Quote(quote: "The apple does not fall far from the tree", author: "Lincoln", index: 1),
Quote(quote: "Not everything that can be faced can be changed, but be sure that nothing can change until it is faced", author: "Unknown", index: 2),
Quote(quote: "Actions are but intentions", author: "Muhammad", index: 3)
]
struct ContentView: View {
#State private var shouldShowQuotes = false
var body: some View {
ZStack {
Color.orange
VStack {
Button(action: showQuotes){
Text("Get Quotes").bold()
.frame(maxWidth: 300)
}
// .controlProminence(.increased) //Safe to uncomment if Xcode 13
// .buttonStyle(.bordered)
// .controlSize(.large)
}
.fullScreenCover(isPresented: $shouldShowQuotes) {
QuoteScreen()
}
}.ignoresSafeArea()
}
private func showQuotes() {
self.shouldShowQuotes.toggle()
}
}
struct QuoteScreen: View {
#State private var quoteIndex = 0
#State var currentQuote: Quote = dummyData[0] // <--- here, do not use "quoteIndex"
var body: some View {
ZStack {
Color.orange
VStack {
QuoteView(quote: $currentQuote) // <--- here
Spacer()
HStack {
Button(action: degress) {
Image(systemName: "arrow.left.square.fill")
.resizable()
.frame(width: 50, height: 50)
}
Spacer()
Button(action: progress) {
Image(systemName: "arrow.right.square.fill")
.resizable()
.frame(width: 50, height: 50)
}
}
.padding(28)
//.buttonStyle(.plain) Safe to uncomment if Xcode 13
}
}.ignoresSafeArea()
}
// you will have to adjust this to your needs
private func progress() {
let prevValue = quoteIndex
quoteIndex += 1
if let thisQuote = dummyData.first(where: { $0.index == quoteIndex}) { // <--- here
currentQuote = thisQuote
} else {
quoteIndex = prevValue
}
}
// you will have to adjust this to your needs
private func degress() {
let prevValue = quoteIndex
quoteIndex -= 1
if let thisQuote = dummyData.first(where: { $0.index == quoteIndex}) { // <--- here
currentQuote = thisQuote
} else {
quoteIndex = prevValue
}
}
}
struct QuoteView: View {
#State private var showQuotes = false
#Binding var quote: Quote // <--- here
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.stroke(lineWidth: 2)
VStack {
Text(quote.quote)
.frame(maxWidth: 300) // <--- here missing leading "."
Text(quote.author)
.frame(maxWidth: 300, alignment: .trailing)
.foregroundColor(.secondary)
}
}.navigationBarHidden(true)
.frame(height: 400)
.padding()
}
}
struct Quote: Identifiable {
let id = UUID()
var quote: String
var author: String
var index: Int
}
This crash is not caused by the array access but by a typo in your code. You can see that if you run it in the simulator and look at the stack trace. It gets in an endless loop in the internals of SwiftUI. The reason is the missing dot before the frame modifier:
struct QuoteView: View {
#State private var showQuotes = false
let quote: Quote
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.stroke(lineWidth: 2)
VStack {
Text(quote.quote)
frame(maxWidth: 300) << !!!!! missing dot
Text(quote.author)
.frame(maxWidth: 300, alignment: .trailing)
.foregroundColor(.secondary)
}
}.navigationBarHidden(true)
.frame(height: 400)
.padding()
}
}
This calls the frame method on the QuoteView and not on the Text - which is an invalid operation.

How to layout properly in ZStack (I have visibility problem)?

Here is reproducable small code below;
As you'll see when you run the demo code, the Element view does stay under Color.blue when dragged eventhough its above according to ZStack. By the way I also played with zIndex modifier but still no luck. Any solution you offer? Thanks all.
struct ContentView: View {
var body: some View {
GeometryReader { gr in
ZStack {
Color.blue.opacity(0.3)
.aspectRatio(1, contentMode: .fit)
.frame(width: gr.size.width)
VStack {
Spacer()
ScrollView(.horizontal) {
HStack {
ForEach(1...15, id: \.self) { (idx) in
Element(index: idx)
}
}
.padding()
}
.background(Color.secondary.opacity(0.3))
}
}
}
}
}
struct Element: View {
#State private var dragAmount = CGSize.zero
var index: Int
var body: some View {
Rectangle()
.frame(width: 80, height: 80)
.overlay(Text("\(index)").bold().foregroundColor(.white))
.offset(dragAmount)
.gesture(
DragGesture(coordinateSpace: .global)
.onChanged {
self.dragAmount = CGSize(width: $0.translation.width, height: $0.translation.height)
}
.onEnded { _ in
self.dragAmount = .zero
}
)
}
}
iOS 15.5: still valid
How can achieve my goal then, like dragging Element on different view (in this scenario Color.blue)
Actually we need to disable clipping by ScrollView.
Below is possible approach based on helper extensions from my other answers (https://stackoverflow.com/a/63322713/12299030 and https://stackoverflow.com/a/60855853/12299030)
VStack {
Spacer()
ScrollView(.horizontal) {
HStack {
ForEach(1...15, id: \.self) { (idx) in
Element(index: idx)
}
}
.padding()
.background(ScrollViewConfigurator {
$0?.clipsToBounds = false // << here !!
})
}
.background(Color.secondary.opacity(0.3))
}

Size a SwiftUI view to be safeAreaInsets.top + 'x'

I am trying to create a full bleed SwiftUI 'header' view in my scene. This header will sit within a List or a scrollable VStack.
In order to do this, I'd like to have my text in the header positioned below the safe area, but the full view should extend from the top of the screen (and thus, overlap the safe area). Here is visual representation:
V:[(safe-area-spacing)-(padding)-(text)]
here is my attempt:
struct HeaderView: View {
#State var spacing: CGFloat = 100
var body: some View {
HStack {
VStack(alignment: .leading) {
Rectangle()
.frame(height: spacing)
.opacity(0.5)
Text("this!").font(.largeTitle)
Text("this!").font(.headline)
Text("that!").font(.subheadline)
}
Spacer()
}
.frame(maxWidth: .infinity)
.background(Color.red)
.background(
GeometryReader { proxy in
Color.clear
.preference(
key: SafeAreaSpacingKey.self,
value: proxy.safeAreaInsets.top
)
}
)
.onPreferenceChange(SafeAreaSpacingKey.self) { value in
self.spacing = value
}
}
}
This however, does not seem to correctly size 'Rectangle'. How can I size a view according to the safe area?
Is this what you're looking for? I try to avoid using GeometryReader unless you really need it... I created a MainView, which has a background and a foreground layer. The background layer will ignore the safe areas (full bleed) but the foreground will stay within the safe area by default.
struct HeaderView: View {
var body: some View {
HStack {
VStack(alignment: .leading) {
Text("this!").font(.largeTitle)
Text("this!").font(.headline)
Text("that!").font(.subheadline)
}
Spacer(minLength: 0)
}
}
}
struct MainView: View {
var body: some View {
ZStack {
// Background
ZStack {
}
.frame(maxWidth:. infinity, maxHeight: .infinity)
.background(Color.red)
.edgesIgnoringSafeArea(.all)
// Foreground
VStack {
HeaderView()
Spacer()
}
}
}
}
add an state to store desired height
#State desiredHeight : CGFloat = 0
then on views body :
.onAppear(perform: {
if let window = UIApplication.shared.windows.first{
let phoneSafeAreaTopnInset = window.safeAreaInsets.top
desiredHeight = phoneSafeAreaTopnInset + x
}
})
set the desiredHeight for your view .
.frame(height : desiredHeight)

SwiftUI reduce spacing of rows in a list to null

I want to reduce the linespacing in a list to null.
My tries with reducing the padding did not work.
Setting ´.environment(.defaultMinListRowHeight, 0)´ helped a lot.
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
List {
ForEach(data, id: \.self)
{ item in
Text("\(item)")
.padding(0)
//.frame(height: 60)
.background(Color.yellow)
}
//.frame(height: 60)
.padding(0)
.background(Color.blue)
}
.environment(\.defaultMinListRowHeight, 0)
.onAppear { UITableView.appearance().separatorStyle = .none }
.onDisappear { UITableView.appearance().separatorStyle = .singleLine }
}
}
}
Changing the ´separatorStyle´ to ´.none´ only removed the Line but left the space.
Is there an extra ´hidden´ view for the Lists row or for the Separator between the rows?
How can this be controlled?
Would be using ScrollView instead of a List a good solution?
ScrollView(.horizontal, showsIndicators: true)
{
//List {
ForEach(data, id: \.self)
{ item in
HStack{
Text("\(item)")
Spacer()
}
Does it also work for a large dataset?
Well, actually no surprise - .separatorStyle = .none works correctly. I suppose you confused text background with cell background - they are changed by different modifiers. Please find below tested & worked code (Xcode 11.2 / iOS 13.2)
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
List {
ForEach(data, id: \.self)
{ item in
Text("\(item)")
.background(Color.yellow) // text background
.listRowBackground(Color.blue) // cell background
}
}
.onAppear { UITableView.appearance().separatorStyle = .none }
.onDisappear { UITableView.appearance().separatorStyle = .singleLine }
}
}
}
Update:
it's not possible to avoid the blue space between the yellow Texts?
Technically yes, it is possible, however for demo it is used hardcoded values and it is not difficult to fit some, while to calculate this dynamically might be challenging... anyway, here it is
it needs combination of stack for compression, content padding for resistance, and environment for limit:
List {
ForEach(data, id: \.self)
{ item in
HStack { // << A
Text("\(item)")
.padding(.vertical, 2) // << B
}
.listRowBackground(Color.blue)
.background(Color.yellow)
.frame(height: 12) // << C
}
}
.environment(\.defaultMinListRowHeight, 12) // << D
I do it the easy SwiftUI way:
struct ContentView: View {
init() {
UITableView.appearance().separatorStyle = .none
}
var body: some View {
List {
ForEach(0..<10){ item in
Color.green
}
.listRowInsets( EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) )
}
}
}
Reduce row spacing is really tricky, try
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)").font(.largeTitle)
.background(Color.yellow)
}.background(Color.green)
.padding(.leading, 10)
.padding(.bottom, -25)
.frame(maxWidth: .infinity)
}
}
}
}
}
It use ScrollView instead of List and negative padding.
I didn't find any solution based on List, we have to ask Apple to publish xxxxStyle protocols and underlying structures.
UPDATE
What about this negative padding value? For sure it depends on height of our row content and unfortunately on SwiftUI layout strategy. Lets try some more dynamic content! (we use zero padding to demostrate the problem to solve)
struct ContentView: View {
#State var data : [CGFloat] = [20, 30, 40, 25, 15]
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)").font(.system(size: item))
.background(Color.yellow)
}.background(Color.green)
.padding(.leading, 10)
//.padding(.bottom, -25)
.frame(maxWidth: .infinity)
}
}
}
}
}
Clearly the row spacing is not fixed value! We have to calculate it for every row separately.
Next code snippet demonstrate the basic idea. I used global dictionary (to store height and position of each row) and tried to avoid any high order functions and / or some advanced SwiftUI technic, so it is easy to see the strategy. The required paddings are calculated only once, in .onAppear closure
import SwiftUI
var _p:[Int:(CGFloat, CGFloat)] = [:]
struct ContentView: View {
#State var data : [CGFloat] = [20, 30, 40, 25, 15]
#State var space: [CGFloat] = []
func spc(item: CGFloat)->CGFloat {
if let d = data.firstIndex(of: item) {
return d < space.count ? space[d] : 0
} else {
return 0
}
}
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)")
.font(.system(size: item))
.background(Color.yellow)
}
.background(
GeometryReader { proxy->Color in
if let i = self.data.firstIndex(of: item) {
_p[i] = (proxy.size.height, proxy.frame(in: .global).minY)
}
return Color.green
}
)
.padding(.leading, 5)
.padding(.bottom, -self.spc(item: item))
.frame(maxWidth: .infinity)
}.onAppear {
var arr:[CGFloat] = []
_p.keys.sorted(by: <).forEach { (i) in
let diff = (_p[i + 1]?.1 ?? 0) - (_p[i]?.1 ?? 0) - (_p[i]?.0 ?? 0)
if diff < 0 {
arr.append(0)
} else {
arr.append(diff)
}
}
self.space = arr
}
}
}
}
}
Running the code I've got