In learning SwiftUI I'm trying to do transition animations I used with UIKit. I sometimes used pages with tables that changed with a parameter by using a containerView and transitioning between children. The SwiftUI equivalent seems to be a single List View dependent on the parameter. But it seems that .transitions don't work well since no view is being removed/inserted in the hierarchy (and simple animations don't give me the transition options I want). The only way I have made it work at all is by "manually" removing the old List and inserting the new one, but that seems a kludge and didn't work perfectly. Here's a toy version of the code that illustrated the problem -- the inserted green rectangle is just for comparison -- the transition works fine for it but not for the List.
struct ContentView: View {
let listItems:[[String]] = [["A0","B0"],["A1","B1"]]
#State var pointer:Int = 0
var body: some View {
VStack{
List(listItems[pointer], id:\.self)
{item in Text(item)
.foregroundColor(self.pointer == 0 ? Color.blue : Color.purple)
}
.transition(.offset(x: -600, y: 0))
if pointer == 1 {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 200)
.foregroundColor(.green)
.transition(.offset(x: 500, y: 300))
}
}
.onTapGesture {
withAnimation(.easeInOut(duration: 1)){
self.pointer = (self.pointer + 1) % 2
}
}
}
}
I've found a partial solution, but it behaves strangely. Instead of a single List with varying content, transitions seem to require separate lists which are inserted/removed. Here is a simple implementation
struct ContentView: View {
let listItems:[[String]] = [["A0","B0"],["A1","B1"],["A2","B2"]]
#State var pointer:Int = 0
var body: some View {
ZStack{ForEach(0...2, id: \.self)
{index in
ZStack{
if index == self.pointer {
List(self.listItems[index], id:\.self)
{item in HStack{Text(item);Spacer()}
.foregroundColor(self.pointer == 0 ? Color.blue : Color.purple)
.frame(height:20)
.padding(2)
.background(Color.yellow)
}
.transition(.asymmetric(insertion: .offset(x: 400, y: -370), removal: .offset(x: 0, y: -870)))
}
}
}
}
.onTapGesture {
withAnimation(.easeInOut(duration: 1)){
self.pointer = (self.pointer + 1) % 3
}
}
But it responds oddly to choice of offsets -- I don't understand the -370 y-offset needed to make insertions come in horizontally -- it was found by trial/error. And the transition 2->0 is different from the other two.
Related
This question is essentially about how to define layout behaviour for a SwiftUI View such that it grows/shrinks in a particular way when given different frames externally. IE imagine you are creating a View which will be packaged up in a library and given to somebody else, without you knowing how much space they will give to your view.
The layout I would like to create will contain two horizontal views, indicated by A & B in my diagrams. I would like to control how this view expands if you specify a frame like follows:
When no frame is specified, I'd like my container View to be as small as the inner views and no bigger. See diagram 1.
When the container View is given a frame that's larger than the inner views, I'd like the space between the inner views to grow. See diagram 2.
Diagram 1: How I'd like my View to look without a frame specified.
// MyView()
| [A B] |
Diagram 2: How I'd like my View to look with a large frame.
// MyView().frame(maxWidth: .infinity)
|[A B]|
Diagram Key:
| represents my Window
[] represents my container View
A and B are my child Views.
My naive attempts:
Unmodified HStack
The behaviour of an unmodified HStack matches Diagram 1 with an unspecified frame successfully, however when given a large frame it's default behaviour is to grow as follows:
// HStack{A B}.frame(maxWidth: .infinity)
|[ AB ]|
HStack with a Spacer between the views
If I use a Stack with but add a spacer in between the views, the spacer grows to take up the most space possible, regardless of what frame is given. IE I end up with a view that looks like Diagram 2 even when no frame is specified.
// HStack{A Spacer B}
|[A B]|
I've been trying to figure out a way to tell a Spacer to prefer to be as small as possible, but to no avail. What other options do we have to achieve this layout?
Edit: To help out, here's some code as a starting point:
struct ContentView: View {
#State var largeFrame: Bool = false
var body: some View {
VStack{
Toggle("Large Frame", isOn: $largeFrame)
HStack {
Text("A")
.border(Color.red, width: 1)
Text("B")
.border(Color.red, width: 1)
}
.padding()
.frame(maxWidth: largeFrame ? .infinity : nil)
.border(Color.blue, width: 1)
}
}
}
I'm a little confused to what you are saying. Are you asking how to generate space between A and B without forcing the HStack to be window width? If so, if you place a frame on the HStack, then the spacer shoulder only separate the contents to as far as the user desires?
struct ContentView: View {
var body: some View {
HStack() {
Text("A")
Spacer()
Text("B")
}
.frame(width: 100)
}
}
EDIT:
Does the following code work? The HStack(spacing: 0) ensures that the contents the HStack have no spacing between the items and so the "smallest" possible.
struct ContentView: View {
#State private var customSpacing = true
#State private var customFrame = CGFloat(100)
var body: some View {
VStack {
Button {
customSpacing.toggle()
} label: {
Text("Custom or Not")
}
if !customSpacing {
HStack(spacing: 0) {
Text("A")
Text("B")
}
} else {
HStack(spacing: 0) {
Text("A")
Spacer()
Text("B")
}
.frame(width: customFrame)
}
}
}
}
If MyView is your component and you have control over its content, then a possible approach is to "override" .frame modifiers (all of them, below is one for demo) and compare explicitly outer width provided by frame and inner width of content subviews.
Tested with Xcode 13.4 / iOS 15.5
Main parts:
struct MyView: View { // << your component
var outerWidth: CGFloat? // << injected width !!
#State private var myWidth = CGFloat.zero // << own calculated !!
// ...
"overridden" frame modifier to store externally provided parameter
#inlinable public func frame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center) -> some View {
var newview = self
newview.outerWidth = maxWidth // << inject frame width !!
return VStack { newview } // << container to avoid cycling !!
.frame(minWidth: minWidth, idealWidth: idealWidth, maxWidth: maxWidth, minHeight: minHeight, idealHeight: idealHeight, maxHeight: maxHeight, alignment: alignment)
}
and conditionally activated space depending on width diffs
SubViewA()
.background(GeometryReader {
Color.clear.preference(key: ViewSideLengthKey.self,
value: $0.frame(in: .local).size.width)
})
if let width = outerWidth, width > myWidth { // << here !!
Spacer()
}
SubViewB()
.background(GeometryReader {
Color.clear.preference(key: ViewSideLengthKey.self,
value: $0.frame(in: .local).size.width)
})
Test module is here
I am creating scrollview as follows and I want to go requested button in array
using Namespace variable.
I saw it used to to go to bottom or top of scrollview but Namespace variables created manually. Because I want to go any button might requested I want to create Namespace
for every item in scrollview not only for top or bottom one.
Is it possible to create Namespace ID using index somehow valueIndex in that case.
I hope it would be something like that selectIndex as Namespace.
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: true){
LazyHStack() {
ForEach(0..<listValues[valueIndex].count, id: \.self) { selectIndex in
Button(action: {
dump(selectIndex)
}){
Text(listValues[valueIndex][selectIndex])
.foregroundColor(Color.white)
.background(Color.red)
.frame(width: w2 * 0.5 * 0.5, height: 40)
} .id(**selectIndex as Namespace**)
}.background(Color.gray)
}
}.frame(width: w2 * 0.5 * 0.5, height: 40)
}
You don't need a NameSpace in a ScrollViewReader, you just need to use any Hashable to identify the place you want to scroll to. You didn't give a Minimal Reproducible Example (MRE), and I couldn't find a straightforward scroll to answer, so I made a basic one to show you how it is done. Please see the comments.
// You should always use an Identifiable in a ForEach. For
// a `scrollTo()`, you only need a `Hashable` `.id()`, so
// this struct has been conformed to Hashable.
struct ScrollItem: Identifiable, Hashable {
let id = UUID()
var name: String
}
struct ScrollToView: View {
let scrollItems: [ScrollItem] = Array(0..<100).map( { ScrollItem(name: "Row " + $0.description) })
#State var selectedItem: ScrollItem?
var body: some View{
ScrollViewReader{ scrollReader in
VStack {
// This button scrolls the selected row to the top, or as high as it can go
Button {
withAnimation {
scrollReader.scrollTo(selectedItem, anchor: .top)
}
} label: {
Text(selectedItem != nil ? "Scroll \(selectedItem!.name) To Top" : "Select Row")
}
List {
// ScrollItem is Identifiable, so you do not need to designate id:
ForEach(scrollItems) { item in
Text(item.name)
.padding()
.frame(maxWidth: .infinity, minHeight: 50, alignment: .leading)
// If the row is selected, this gives a colored background
.background(
RoundedRectangle(cornerRadius: 15)
.fill(selectedItem == item ? Color.yellow.opacity(0.4) : Color.clear)
)
.contentShape(RoundedRectangle(cornerRadius: 15))
// This allows you to select and deselect the row
.onTapGesture {
if selectedItem == item {
selectedItem = nil
} else {
selectedItem = item
}
}
// ScrollItem conforms to Hashable, so it can be used as the .id()
.id(item)
}
}
// This button scrolls the selected row to the bottom, or as low as it can go
Button {
withAnimation {
scrollReader.scrollTo(selectedItem, anchor: .bottom)
}
} label: {
Text(selectedItem != nil ? "Scroll \(selectedItem!.name) To Bottom" : "Select Row")
}
}
}
}
}
I'm looking for a similar way https://github.com/stokatyan/ScrollCounter in SwiftUI
This is a very rudimentary build of the same thing. I'm not sure if you're wanting to do it on a per-digit basis, however this should give you a solid foundation to work off of. The way that I'm handling it is by using a geometry reader. You should be able to easily implement this view by utilizing an HStack for extra digits/decimals. The next thing I would do would be to create an extension that handles returning the views based on the string representation of your numeric value. Then that string is passed as an array and views created for each index in the array, returning a digit flipping view. You'd then have properties that are having their state observed, and change as needed. You can also attach an .opacity(...) modifier to give it that faded in/out look, then multiply the opacity * n where n is the animation duration.
In this example you can simply tie your Digit value to the previewedNumber and it should take over from there.
struct TestView: View {
#State var previewedNumber = 0;
var body: some View {
ZStack(alignment:.bottomTrailing) {
GeometryReader { reader in
VStack {
ForEach((0...9).reversed(), id: \.self) { i in
Text("\(i)")
.font(.system(size: 100))
.fontWeight(.bold)
.frame(width: reader.size.width, height: reader.size.height)
.foregroundColor(Color.white)
.offset(y: reader.size.height * CGFloat(previewedNumber))
.animation(.linear(duration: 0.2))
}
}.frame(width: reader.size.width, height: reader.size.height, alignment: .bottom)
}
.background(Color.black)
Button(action: {
withAnimation {
previewedNumber += 1
if (previewedNumber > 9) {
previewedNumber = 0
}
}
}, label: {
Text("Go To Next")
}).padding()
}
}
}
I have the following code:
#State private var couponId: Int = 0
var body: some View {
ScrollView {
VStack(spacing: -50) {
ForEach(1...20, id: \.self) { index in
CouponCell()
.zIndex(couponId == index ? 2 : 0)
.animation(.spring())
.onTapGesture {
withAnimation {
couponId = index
}
}
}
}
.padding().frame(maxWidth: .infinity, maxHeight: .infinity)
The the user selects a particular CouponCell then I update the Zindex so it appears on top of other cells. This works but it does not animate at all. What am I missing and why animation is not taking place.
I am using Xcode 12 Beta 2 on macOS Big Sur Beta 2
The .zIndex itself is not animatable modifier, because actually nothing to animate in view z position - it is either below or above.
Probably you meant (or would prefer) something combined like the following
Tested on replicated code (Xcode 12)
CouponCell()
.zIndex(couponId == index ? 2 : 0)
.scaleEffect(couponId == index ? 1.02 : 1)
.animation(.spring(), value: couponId)
.onTapGesture {
couponId = index
}
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