I'm trying to decipher how identity works in SwiftUI. Demystify SwiftUI helps, but essentially suggests there is explicit or structural identity, which is used to determine state and transitions.
Let's apply the concept on a simple test case, where I would like to shuffle two blocks back-and-forth by pressing a toggle button. The following example accomplishes this using 2 alternative methods, one with a ForEach and one which spells each out explicitly. Oddly enough, only the ForEach method appears to allow the blocks to maintain identity as they shuffle around in the container – spelling the views out with an explicit identity breaks the animation:
struct SimplePreview: PreviewProvider, View {
static var previews = Self()
#State
private var isRunning = false
var body: some View {
VStack {
Button("Toggle is \(self.isRunning ? "on" : "off")") {
withAnimation {
self.isRunning.toggle()
}
}
// 1
HStack {
ForEach([self.isRunning, !self.isRunning], id: \.self) {
Rectangle()
.fill($0 ? Color.accentColor : Color.gray)
}
}
// 2
HStack {
Rectangle()
.fill(self.isRunning ? Color.blue : Color.gray)
Rectangle()
.fill(!self.isRunning ? Color.blue : Color.gray)
}
// 3
HStack {
Rectangle()
.fill(self.isRunning ? Color.blue : Color.gray)
.id(self.isRunning)
Rectangle()
.fill(!self.isRunning ? Color.blue : Color.gray)
.id(!self.isRunning)
}
}
}
}
What I take from this is that in case:
ForEach allows the individual items to maintain their full identity
The individual items have a separate structural identity and as their state changes, they transition individually.
The individual items have a separate structural identity, but when their internal .id changes, they are seen as completely new views, breaking animation entirely.
The question is then obviously:
How can maintain full view identity outside of something like ForEach? We want to achieve the effect seen in [1] using an approach like [2] or [3].
I believe you can achieve this with matchedGeometryEffect and #Namespace, e.g.
#Namespace private var animation
// 2
HStack {
if isRunning {
Rectangle()
.fill(Color.blue)
.matchedGeometryEffect(id: 1, in: animation)
.transition((.scale(scale: 1))) // this is to override the default transition which is opacity that looks bad.
Rectangle()
.fill(Color.gray)
.matchedGeometryEffect(id: 2, in: animation)
.transition(.scale(scale: 1))
}
else {
Rectangle()
.fill(Color.gray)
.matchedGeometryEffect(id: 2, in: animation)
.transition(.scale(scale: 1))
Rectangle()
.fill(Color.blue)
.matchedGeometryEffect(id: 1, in: animation)
.transition(.scale(scale: 1))
}
Here is a tutorial covering this feature.
Here is another fun way of achieving a similar animation, the difference being the blue always slides behind the gray.
// 4
HStack {
Rectangle()
.fill(Color.blue)
Rectangle()
.fill(Color.gray)
}
.environment(\.layoutDirection, isRunning ? .leftToRight : .rightToLeft)
Related
We have a custom textfield defined in SwiftUI. This is the basic code for it:
var body: some View {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: Constants.cornerRadius, style: .continuous)
.stroke(isFocused ? Color.blue : hasWarning ? .red : .gray, lineWidth: Constants.lineWidth)
HStack {
Text(title)
.font(.body)
.foregroundColor(isFocused ? Color.blue : hasWarning ? .red : .gray)
.padding(.horizontal, text.isEmpty ? Constants.Padding.horizontalIsEmpty : Constants.Padding.horizontalIsNotEmpty)
.background(text.isEmpty ? Color.clear : background)
.padding(.leading, Constants.Padding.leading)
.offset(y: text.isEmpty ? Constants.Offset.isEmpty : -(frameHeight / Constants.Offset.isNotEmptyRatio))
.scaleEffect(text.isEmpty ? Constants.ScaleEffect.isEmpty : Constants.ScaleEffect.isNotEmpty, anchor: .leading)
}
HStack {
TextField("", text: $text, onEditingChanged: { inFocus in
self.isFocused = inFocus
})
.font(.body)
.padding(.horizontal, Constants.Padding.horizontal)
}
}
.frame(height: frameHeight)
.background(background)
.animation(.easeInOut(duration: Constants.Animation.easeInOut))
.padding(.top, Constants.Padding.top)
}
Currently there's no viewModel linked to this. Now we want to extend this component to be able to include one (or both of):
An internal button
An icon in the textfield itself
I want to avoid adding any logic into the view and so I initially created a viewModel that looks something like this:
struct InternalTextfieldButtonProperties {
let action: () -> Void
let labelText: String
}
class TextFieldFloatingWithBorderViewModel: ObservableObject {
let internalButtonProperties: InternalTextfieldButtonProperties?
let icon: Image?
var hasButton: Bool {
return internalButtonProperties != nil
}
var hasIcon: Bool {
return icon != nil
}
init(internalButtonProperties: InternalTextfieldButtonProperties? = nil, icon: Image? = nil) {
self.internalButtonProperties = internalButtonProperties
self.icon = icon
}
}
The idea being that you can initialise this and then use the viewModel properties within the view to display the a button and or icon. However, this all seems a bit counterproductive as the icon and button properties in the viewModel would still need to be optional, which means again unwrapping in the view, taking away some of the point of having the viewModel. Indeed, if no icon or button is required, you end up declaring a viewModel for no reason.
I wondered if there's a cleaner way of doing this?
I want to create the following design in SwiftUI. I am currently using a list and creating a section that contains cells like so.
List {
Section {
ForEach(titles) { title in
Cell(title: title)
}
}
}
When I apply a modifier like a border to the section it applies it to all the views contained in the Section. I want to have that border around the entire Section with a corner radius of 10. The closest I have got to creating the desired design is by not using a List but instead using a VStack and applying the following modifiers
VStack {
ForEach(titles) { title in
Cell(title: title)
}
}
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(.gray, lineWidth: 2))
I discovered however that this is not a smart approach as the List uses reusable cells and in the case of VStack they do not. Is it possible to create the wanted design with a List in SwiftUI? I do not want to opt for the default list style provided by Apple
Just Copy paste this code and customise it as per your needs, enjoy
import SwiftUI
struct CustomizeListView: View {
var titles = ["First Section" : ["Manage your workout", "View recorded workouts", "Weight tracker", "Mediation"], "Second Section" : ["Your workout", "Recorded workouts", "Tracker", "Mediations"]]
var body: some View {
List {
ForEach(titles.keys.sorted(by: <), id: \.self){ key in
Section(key) {
VStack(alignment: .leading, spacing: 0){
ForEach(titles[key]!, id: \.self) { title in
HStack{
Text(title)
Spacer()
Image(systemName: "arrow.right")
}//: HSTACK
.padding(20)
Divider()
}//: LOOP
}//: VSTACK
.overlay(
RoundedRectangle(cornerRadius: 10, style: .circular).stroke(Color(uiColor: .tertiaryLabel), lineWidth: 1)
)
.foregroundColor(Color(uiColor: .tertiaryLabel))
}//: SECTION
}//: LOOP
}//: LIST
.listStyle(InsetListStyle())
}
}
struct CustomizeListView_Previews: PreviewProvider {
static var previews: some View {
CustomizeListView()
}
}
I assume you just need to change list style, like
List {
Section {
ForEach(titles) { title in
Cell(title: title)
}
}
}
.listStyle(.insetGrouped) // << here !!
I am trying to replicate what I used to do in UIKit when presenting a ViewController using UIViewControllerTransitioningDelegate and UIViewControllerAnimatedTransitioning.
So, for example, from a view that looks like this:
I want to present a view (I would say a modal view but I am not sure if that is the correct way to go about it in SwiftUI) that grows from the view A into this:
So, I need view B to fade in growing from a frame matching view A into almost full screen. The idea is the user taps on A as if it wanted to expand it into its details (view B).
I looked into SwiftUI transitions, things like this:
extension AnyTransition {
static var moveAndFade: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
}
So, I think I need to build a custom transition. But, I am not sure how to go about it yet being new to this.
How would I build a transition to handle the case as described? Being able to have a from frame and a to frame...?
Is this the right way of thinking about it in SwiftUI?
New information:
I have tested matchedGeometryEffect.
Example:
struct TestParentView: View {
#State private var expand = false
#Namespace private var shapeTransition
var body: some View {
VStack {
if expand {
// Rounded Rectangle
Spacer()
RoundedRectangle(cornerRadius: 50.0)
.matchedGeometryEffect(id: "circle", in: shapeTransition)
.frame(minWidth: 0, maxWidth: .infinity, maxHeight: 300)
.padding()
.foregroundColor(Color(.systemGreen))
.animation(.easeIn)
.onTapGesture {
expand.toggle()
}
} else {
// Circle
RoundedRectangle(cornerRadius: 50.0)
.matchedGeometryEffect(id: "circle", in: shapeTransition)
.frame(width: 100, height: 100)
.foregroundColor(Color(.systemOrange))
.animation(.easeIn)
.onTapGesture {
expand.toggle()
}
Spacer()
}
}
}
}
It looks like matchedGeometryEffect could be the tool for the job.
However, even when using matchedGeometryEffect, I still can't solve these two things:
how do I include a fade in / fade out animation?
looking at the behavior of matchedGeometryEffect, when I "close" view B, view B disappears immediately and what we see animating is view A from where B was back to view A's original frame. I actually want view B to scale down to where A is as it fades out.
You would have to use the .matchedGeometryEffect modifier on the two Views that you would like to transition.
Here is an example:
struct MatchedGeometryEffect: View {
#Namespace var nspace
#State private var toggle: Bool = false
var body: some View {
HStack {
if toggle {
VStack {
Rectangle()
.foregroundColor(Color.green)
.matchedGeometryEffect(id: "animation", in: nspace)
.frame(width: 300, height: 300)
Spacer()
}
}
if !toggle {
VStack {
Spacer()
Rectangle()
.foregroundColor(Color.blue)
.matchedGeometryEffect(id: "animation", in: nspace)
.frame(width: 50, height: 50)
}
}
}
.padding()
.overlay(
Button("Switch") { withAnimation(.easeIn(duration: 2)) { toggle.toggle() } }
)
}
}
Image should be a GIF
The main two parts of using this modifier are the id and the namespace.
The id of the two Views you are trying to match have to be the same. They then also have to be in the same namespace. The namespace is declared at the top using the #Namespace property wrapper. In my example I used "animation", but it can really be anything, preferably something that can uniquely identify the Views from other types of animations.
Another important piece of information is that the '''#State''' variable controlling the showing/hiding of Views is animated. This is done through the use of withAnimation { toggle.toggle() }.
I'm also quite new to this, so for some more information you can read this article I found from the Swift-UI Lab:
https://swiftui-lab.com/matchedgeometryeffect-part1/
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'm not quite a SwiftUI veteran but I've shipped a couple of apps of moderate complexity. Still, I can't claim that I fully understand it and I'm hoping someone with deeper knowledge could shed some light on this issue:
I have some content that I want to toggle on and off, not unlike .sheet(), but I want more control over it. Here is some "reconstructed" code but it should be able capture the essence:
struct ContentView: View {
#State private var isShown = false
var body: some View {
GeometryReader { g in
VStack {
ZStack(alignment: .top) {
// This element "holds" the size
// while the content is hidden
Color.clear
// Content to be toggled
if self.isShown {
ScrollView {
Rectangle()
.aspectRatio(1, contentMode: .fit)
.frame(width: g.size.width) // This is a "work-around"
} // ScrollView
.transition(.move(edge: .bottom))
.animation(.easeOut)
}
} // ZStack
// Button to show / hide the content
Button(action: {
self.isShown.toggle()
}) {
Text(self.isShown ? "Hide" : "Show")
}
} // VStack
} // GeometryReader
}
}
What it does is, it toggles on and off some content block (represented here by a Rectangle within a ScrollView). When that happens, the content view in transitioned by moving in from the bottom with some animation. The opposite happens when the button is tapped again.
This particular piece of code works as intended but only because of this line:
.frame(width: g.size.width) // This is a "work-around"
Which, in turn, requires an extra GeometryReader, otherwise, the width of the content is animated, producing an unwanted effect (another "fix" I've discovered is using the .fixedSize() modifier but, to produce reasonable effects, it requires content that assumes its own width like Text)
My question to the wise is: is it possible to nicely transition in content encapsulated within a ScrollView without using such "fixes"? Alternatively, is there a more elegant fix for that?
A quick addition to the question following #Asperi's answer: contents should remain animatable.
You are my only hope,
–Baglan
Here is a solution (updated body w/o GeometryReader). Tested with Xcode 11.4 / iOS 13.4
var body: some View {
VStack {
ZStack(alignment: .top) {
// This element "holds" the size
// while the content is hidden
Color.clear
// Content to be toggled
if self.isShown {
ScrollView {
Rectangle()
.aspectRatio(1, contentMode: .fit)
.animation(nil) // << here !!
} // ScrollView
.transition(.move(edge: .bottom))
.animation(.easeOut)
}
} // ZStack
// Button to show / hide the content
Button(action: {
self.isShown.toggle()
}) {
Text(self.isShown ? "Hide" : "Show")
}
} // VStack
}