Changing icon causes a hitch in SwiftUI animation? - ios

I have a list of tags where when you select them a chip will appear.
When you click on the 'X' on each chip, the tag should slide out with an animation, and the tag in the list will be marked as unselected.
The problem I'm having is when I remove the last chip, the circle/circle-check to the left of the tag does not flow smoothly with the animation.
I believe that is because the icon is changing when selected vs. unselected, since if I keep the icon the same this is not a problem. It's also not a problem if I remove the slide animation on the chips, however I like this animation and would like to keep it.
I'm actually having this issue in a few places in my app that involve animations + changing icons and was wondering if there's a workaround for this?
I've attached a reproducible example below.
import SwiftUI
struct ContentView: View {
var body: some View {
Icon_Animation()
}
}
struct Icon_Animation: View {
//All tags
var testTags: [Tag] =
[Tag("tag1"),
Tag("tag2"),
Tag("tag3")]
//Only tags that have been selected
#State var selectedTags = [Tag]()
var body: some View {
ScrollView{
//Hstack of the tags that have been selected
HStack{
ForEach(selectedTags){ tag in
HStack(spacing: 0){
Button{
//Clicking on the X will remove from selectedTags array, and then make that tag's isSelected = false
withAnimation(.easeOut) {
if let index = selectedTags.firstIndex(where: {$0.name == tag.name}){
selectedTags.remove(at: index)
}
}
//PROBLEM: even though this statemnt isn't in the withAnimation block, it causes a weird behavior with the circle/check-circle icon
//If I remove the withAnimation statement from the above block, it works fine. However, I would like to keep the slide animation on the chips.
tag.isSelected = false
}label:{
Image(systemName: "x.circle.fill")
.font(.subheadline)
.padding(.horizontal, 6)
}
Image(systemName: "number")
.font(.footnote.weight(.bold))
.padding(.trailing, 2)
Text("\(tag.name)")
.font(.footnote)
}
.padding(.trailing, 20)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.6), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.transition(.slide)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
//List of tags where you can select each tag to create a chip
ForEach(testTags){ tag in
TagView(tag: tag)
.onTapGesture {
tag.isSelected.toggle()
if(tag.isSelected == true){
selectedTags.append(tag)
}
}
}
.padding()
}
.padding()
}
}
class Tag: Identifiable, ObservableObject {
var id = UUID()
#Published var name: String
#Published var isSelected = false
init(_ name: String){
self.name = name
}
}
struct TagView: View {
#ObservedObject var tag: Tag = Tag("test")
var body: some View {
ZStack{
//Overlay for when tag is selected
Rectangle()
.fill(Color.purple.opacity(0.6))
.edgesIgnoringSafeArea(.all)
.cornerRadius(5)
.opacity(tag.isSelected ? 1 : 0)
HStack(spacing: 8){
//PROBLEM!!: I want to use a different icon based on whether tag isSelected, but it's causing a hitch in the animation when switching
if(tag.isSelected){
Image(systemName: "checkmark.circle.fill")
.font(.title2.weight(.light))
}else{
Image(systemName: "circle")
.font(.title2.weight(.light))
}
Image(systemName: "number")
.font(.body.weight(.bold))
Text(tag.name)
.font(.headline)
.fontWeight(.bold)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}

I think this is a common problem that comes down to the a parent view receiving two different child views from an if statement, and as they are two different views, with different ids, SwiftUI doesn't know how to animate between them.
The trick is is to use just one child view with variable content. You should be able to achieve this by replacing the if...else... in your TagView that generates the image with a ternary inside of the initialiser:
Image(systemName: tag.isSelected ? "checkmark.circle.fill" : "circle" )
.font(.title2.weight(.light))

Related

SwiftUI expandable component Animation issue

I created a custom bottom bar with horizontal expandable tabs.
I have two animations: (1) tab expand/collapse animation, (2) tab bar translation animation (when some tab was expanded, it affects move other tabs)
struct AirTabView: View {
#Binding var isActive: Bool
var model: TabModel
var action: (() -> ())
var body: some View {
HStack(spacing: 10) {
Image(model.imageName)
.foregroundColor(.black)
if isActive {
Text(model.title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.init(uiColor: .label)
)
.lineLimit(1)
}
}
.padding(10)
.background(isActive ? Color(.secondarySystemBackground) : .clear)
.cornerRadius(11)
.onTapGesture(perform: action)
.animation(.linear(duration: 2), value: isActive)
}
}
struct AirTabBar: View {
var tabs: [TabModel]
var actions: [TabActionModel]
#State private var selectedIndex = 0
var body: some View {
HStack(spacing: 10) {
ForEach(0..<tabs.count, id: \.self) { index in
AirTabView(isActive: .constant(selectedIndex == index), model: tabs[index]) {
selectedIndex = index
}
}
Spacer()
ForEach(0..<actions.count, id: \.self) { index in
AirTabActionView(model: actions[index])
}
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background()
.cornerRadius(16)
.shadow(
color: .init(uiColor: .black
.withAlphaComponent(0.07)
),
radius: 15,
x: 2)
.animation(.linear(duration: 2))
}
}
But sometimes, I have a visual bug when text that appears in an expanded cell overlaps the image at the animation start. I want that text always be and appear right side of the image.
Please explain to me what I did wrong. Sometimes RIGHT behavior happens, but I want to understand and fix WRONG
Expected effect is not clear, but observed behavior is due to transition (by default it is opacity), ie. when text is added conditionally it appears with opacity transition.
Here is a demo how it could be managed (so you can tune more if some other effect is needed).
Tested with Xcode 13.4 / iOS 15.5 (some missed things replaced)
Main part:
HStack(spacing: 10) {
if isActive {
HStack {
Image(systemName: model.imageName) // system name for testing !!
.foregroundColor(.black)
Text(model.title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.init(uiColor: .label)
)
.lineLimit(1)
}
.transition(.identity) // content is same !!
} else {
Image(systemName: model.imageName)
.foregroundColor(.black)
.transition(.identity) // same is here !!
}
}
.animation(.none, value: isActive) // << don't animate !!
.padding(10)
so content of label is not animated and replaced Image <> Image Title, which gives title always appears right after image, and only highlighting box is animated.
Test module on GitHub

Custom ScrollView Indicator in SwiftUI

Is it possible to create a custom horizontal indicator that has empty and filled circles to show how many images there are and the current position?
The below attempt uses a lazyHStack and OnAppear but, judging from the console output, it doesn't work properly since scrolling back and forth doesn't recall the onAppear consistently.
import SwiftUI
struct ContentView: View {
let horizontalScrollItems = ["wind", "hare.fill", "tortoise.fill", "rosette" ]
var body: some View {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(horizontalScrollItems, id: \.self) { symbol in
Image(systemName: symbol)
.font(.system(size: 200))
.frame(width: geometry.size.width)
.onAppear(){print("\(symbol)")}
}
}
}
}
}
}
This is the desired indicator. I'm just not sure how to properly fill and empty each circle as the user scrolls back and forth. Appreciate the help!
You can get the desired result using TabView() and PageTabViewStyle()
Note : This will work from SwiftUI 2.0
Here is the code :
struct ContentView: View {
let horizontalScrollItems = ["wind", "hare.fill", "tortoise.fill", "rosette" ]
var body: some View {
GeometryReader { geometry in
TabView(){
ForEach(horizontalScrollItems, id: \.self) { symbol in
Image(systemName: symbol)
.font(.system(size: 200))
.frame(width: geometry.size.width)
}
}
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
}
}
}
Result :

Why SwiftUI context menu show all row view in preview?

I have a complex view in List row:
var body: some View {
VStack {
VStack {
FullWidthImageView(ad)
HStack {
Text("\(self.price) \(self.ad.currency!)")
.font(.headline)
Spacer()
SwiftUI.Image(systemName: "heart")
}
.padding([.top, .leading, .trailing], 10.0)
Where FullWidthImageView is view with defined contexMenu modifier.
But when I long-press on an image I see not the only image in preview, but all row view.
There is no other contextMenu on any element.
How to make a preview in context with image only?
UPD. Here is a simple code illustrating the problem
We don't have any idea why in your case it doesn't work, until we see your FullWidthImageView and how you construct the context menu. Asperi's answer is working example, and it is correctly done! But did it really explain your trouble?
The trouble is that while applying .contextMenu modifier to only some part of your View (as in your example) we have to be careful.
Let see some example.
import SwiftUI
struct FullWidthImageView: View {
#ObservedObject var model = modelStore
var body: some View {
VStack {
Image(systemName: model.toggle ? "pencil.and.outline" : "trash")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200)
}.contextMenu(ContextMenu {
Button(action: {
self.model.toggle.toggle()
}) {
HStack {
Text("toggle image to?")
Image(systemName: model.toggle ? "trash" : "pencil.and.outline")
}
}
Button("No") {}
})
}
}
class Model:ObservableObject {
#Published var toggle = false
}
let modelStore = Model()
struct ContentView: View {
#ObservedObject var model = modelStore
var body: some View {
VStack {
FullWidthImageView()
Text("Long press the image to change it").bold()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
while running, the "context menu" modified View seems to be "static"!
Yes, on long press, you see the trash image, even though it is updated properly while you dismiss the context view. On every long press you see trash only!
How to make it dynamic? I need that the image will be the same, as on my "main View!
Here we have .id modifier. Let see the difference!
First we have to update our model
class Model:ObservableObject {
#Published var toggle = false
var id: UUID {
UUID()
}
}
and next our View
FullWidthImageView().id(model.id)
Now it works as we expected.
For another example, where "standard" state / binding simply doesn't work check SwiftUI hierarchical Picker with dynamic data crashes
UPDATE
As a temporary workaround you can mimic List by ScrollView
import SwiftUI
struct Row: View {
let i:Int
var body: some View {
VStack {
Image(systemName: "trash")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200)
.contextMenu(ContextMenu {
Button("A") {}
Button("B") {}
})
Text("I don’t want to show in preview because I don’t have context menu modifire").bold()
}.padding()
}
}
struct ContentView: View {
var body: some View {
VStack {
ScrollView {
ForEach(0 ..< 20) { (i) in
VStack {
Divider()
Row(i: i)
}
}
}
}
}
}
It is not optimal, but in your case it should work
Here is a code (simulated possible your scenario) that works, ie. only image is shown for context menu preview (tested with Xcode 11.3+).
struct FullWidthImageView: View {
var body: some View {
Image("auto")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200)
.contextMenu(ContextMenu() {
Button("Ok") {}
})
}
}
struct TestContextMenu: View {
var body: some View {
VStack {
VStack {
FullWidthImageView()
HStack {
Text("100 $")
.font(.headline)
Spacer()
Image(systemName: "heart")
}
.padding([.top, .leading, .trailing], 10.0)
}
}
}
}
It's buried in the replies here, but the key discovery is that List is changing the behavior of .contextMenu -- it creates "blocks" that pop up with the menu instead of attaching the menu to the element specified. Switching out List for ScrollView fixes the issue.

Problems with layout of some rows in SwiftUI list

I have tried to find a similar problem asked before, but failed.
I have a simple view with list. I am using a ForEach to show 10 iterations of the some list item to create a layout before I will add real data to this list. I have a problem with last 2 rows not rendering correctly. But sometimes it’s other row. I have tested on an iPhone too and sometimes it’s one row, sometimes another. The code for the view with list is this:
import SwiftUI
struct LocksView: View {
#State private var locksPaid = 0
var body: some View {
NavigationView {
List {
DateView()
.listRowInsets(EdgeInsets())
Picker(selection: $locksPaid, label: Text("Picker")) {
Text("All").tag(0)
Text("Not paid (2)").tag(1)
}
.pickerStyle(SegmentedPickerStyle())
.padding(10)
ForEach(0 ..< 10) {item in
LocksItemView()
}
}
.navigationBarTitle(Text("Locks"))
.navigationBarItems(trailing: EditButton())
}
}
}
The code for list items is this:
import SwiftUI
struct LocksItemView: View {
#State private var paid : Bool = false
var body: some View {
HStack {
Text("L15")
.font(.title)
.fontWeight(.heavy)
.multilineTextAlignment(.center)
.frame(width: 80)
VStack(alignment: .leading) {
Text("nickname")
.fontWeight(.bold)
Text("category")
Text("4 000 THB")
.fontWeight(.bold)
}
Spacer()
Toggle(isOn: self.$paid) {
Text("Paid")
}
.labelsHidden()
}
}
}
Why is toggle broken in some rows on my list? Why it moves to the left side?
It is not last items. If you set in your ForEach 20 instead of 10 and scroll up & down you'll see much more interesting issue.
I assume the reason, actually List bug, is the same as in this topic.
Workaround If it is not critical to you then use ScrollView instead of List, as tested there is no bug for it. Otherwise, file a Radar and wait for fix.
I tried your code at simulators first and had same issue too. But then I remembered, that there are some problems with 13.2 iOS and tried to run it on my device (iPhone 7, iOS 13.1.1) and everything works fine! I think that is the problem in 13.2 iOS, not in the List. There is sample, how I changed code for demonstration that everything is ok:
import SwiftUI
struct LocksView: View {
#State private var locksPaid = 0
var body: some View {
NavigationView {
List {
Picker(selection: $locksPaid, label: Text("Picker")) {
Text("All").tag(0)
Text("Not paid (2)").tag(1)
}
.pickerStyle(SegmentedPickerStyle())
.padding(10)
ForEach(0 ..< 200) {item in
LocksItemView(number: item)
}
}
.navigationBarTitle(Text("Locks"))
.navigationBarItems(trailing: EditButton())
}
}
}
struct LocksItemView: View {
#State private var paid : Bool = false
var number: Int
var body: some View {
HStack {
Text("L\(self.number)")
.font(.title)
.fontWeight(.heavy)
.multilineTextAlignment(.center)
.frame(width: 80)
VStack(alignment: .leading) {
Text("nickname")
.fontWeight(.bold)
Text("category")
Text("4 000 THB")
.fontWeight(.bold)
}
Spacer()
Toggle(isOn: self.$paid) {
Text("Paid")
}
.labelsHidden()
}
}
}
and on my phone the result is:
so there are bugs in 13.2 version and I hope Apple will fix them all

SwiftUI View displayed with blue background

I'm trying to reproduce the Apple tutorial(Composing Complex Interfaces) and I have a very weird problem. My CategoryItem view is being displayed as a blue frame.
If I remove the NavigationLink which wraps it, everything works fine but with that one it doesn't.
struct CategoryRow: View {
var categoryName: String
var items: [Landmark]
var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
NavigationLink(
destination: LandmarkDetail(
landmark: landmark
)
) {
CategoryItem(landmark: landmark)
}
}
}
}.frame(height: 185)
}
}
}
NavigationLink has a blue accent color by default, just call .accentColor(Color.clear) on it
Or you could try this:
NavigationView {
NavigationLink(destination: Text("Detail view here")) {
Image("YourImage")
}
.buttonStyle(PlainButtonStyle())
}
https://www.hackingwithswift.com/quick-start/swiftui/how-to-disable-the-overlay-color-for-images-inside-button-and-navigationlink
renderingMode(.original) is what did it for me; .accentColor(Color.clear) made the image invisible (my best explanation here is because it didn't have a transparency).
NavigationView {
NavigationLink(destination: Text("Detail view here")) {
Image("YourImage")
.renderingMode(.original)
}
}
As the answer above mentioned, How to disable the overlay color for images inside Button and NavigationLink is a good write up as well.

Resources