SwiftUI expandable component Animation issue - ios

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

Related

Changing icon causes a hitch in SwiftUI animation?

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))

Button style label image and text foreground color change at a different time?

I have a question about a part of my code in my custom button style. So I created a custom button style and this is what I am returning:
struct CustomStyle: ButtonStyle{
func makeBody(configuration: Configuration) -> some View {
return AnyView(Label {
configuration.label
} icon: {
Image(systemName: "plus").resizable().frame(width: 30, height: 30)
})
.foregroundColor(
configuration.isPressed ? Color.blue : Color.red
)
.padding()
.frame(maxWidth: .infinity, minHeight: 20)
.background(Color.yellow)
.clipShape(Capsule())
}
}
struct ContentView: View {
var body: some View {
VStack {
Button(action: {}, label: { Text("amin") })
.buttonStyle(CustomStyle())
}
}
}
The foregroundColor should change when I tap the button, and it does change. The issue is, the icon takes a few more milliseconds to go back to its original color. For example, let's say the color of the text and icon is red. When I click on the button both become blue, but text goes back to red immediately once I let go and icon(image) goes back to red with a very brief(a few millisecond) animation. I want both to be synced.
notes:
I know that most of the time in button styles we just return configuration.label, but what I am returning also works and has no issues. I have to do this because I want my button to have an image next to its text.
icon in this case is Image(systemName: "plus")
It is possible to fix by just disabling animation (tested with Xcode 13.4 / iOS 15.5)
struct CustomStyle: ButtonStyle{
func makeBody(configuration: Configuration) -> some View {
Label {
configuration.label
} icon: {
Image(systemName: "plus").resizable().frame(width: 30, height: 30)
}
.animation(.none, value: configuration.isPressed) // << here !!
.foregroundColor(
configuration.isPressed ? Color.blue : Color.red
)

SwiftUI Dismiss keyboard on List NavigationLink item tap

I have a list of items and a text field for search keywords. When I do search on the list and tap on items, navigationlink works faster than keyboard dismiss, and this causes a disabled area in the next scroll view.
// search TextField
HStack {
Spacer(minLength: 10)
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
TextField("Search",
text: self.$textbox){
UIApplication.shared.endEditing()
}
.onChange(of: textbox) {
print($0)
dictionaryListVM.getResult(language: self.currentLanguage, text: self.textbox)
self.theId += 1
}
.accessibility(identifier: "loginUserName")
.background(Color.white)
.frame(height: 10, alignment: .leading)
.padding()
}.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(Color.gray, lineWidth: 2)
)
// Subview for displaying items:
GeometryReader { reader in
ScrollView {
VStack {
if model.dataLoaded {
List {
ForEach(self.model.englishDictionaries, id: \.id) { item in
ZStack {
VStack(alignment: .leading, spacing: 4) {
Text("\(item.value)").font(.title2)
Text("\(item.pairWord)").foregroundColor(.gray).font(.caption)
}
NavigationLink(destination: WordDetailView(englishWordId: item.id, englishWord: item.value, lang: "en")) {
}
.isDetailLink(false)
.buttonStyle(PlainButtonStyle()).frame(width:0).opacity(0)
}
}
}.frame(height: reader.size.height)
.animation(.linear)
}else{
Text("Words sync inprogress, please comeback later.")
}
}
}.padding(.leading, 10)
}
So, I want to make sure that my keyboard is dismayed before navigation to the next view.
check out demo for the issue in action
If you are using iOS 15
I believe you can make use of the #FocusState Property wrapper to dismiss the keyboard.
First you will have to define a variable to hold the focus value.
#FocusState private var isFocused: Bool
Adding the .focused(_:) View Modifier to the TextField:
TextField("Hello There", text: $someText)
.focused($isFocused)
Toggling $isFocused on button press or navigation link press. You can set it as shown below:
isFocused = false

Image positionning issue in SwiftUI

I am having an alignment problem while using SwiftUI.
Maybe I should say a layout issue. Anyway here is the situation:
This is the relevant part of the app interface:
One can see that while the text ("+++++") is centered, the flag is not. It is slightly shifted to the left. This left-shifting is precisely my problem. I would like the image to be centered as the text is.
Here follows the code, I would like to know what I am doing wrong for the image not to be centered:
import SwiftUI
struct TheNiceView: View {
........
var body: some View {
VStack {
HStack {
Spacer()
TheButtonView()
Spacer()
}
HStack {
Spacer()
Button(action: {})
{
Text("+++++")
.font(.largeTitle)
.foregroundColor(.gray)
.fontWeight(.heavy)
}
Spacer()
}
}
}
}
struct TheButtonView: View {
........
let imgSide:CGFloat = 72.0
var body: some View {
HStack {
Button(action: {})
{
Image(uiImage: ThaiFlagImg)
.resizable()
.frame(width: imgSide, height: imgSide)
}
}
}
}
Just in case this may be useful, this is the image used for the flag:
1
Thailand flag has five horizontal stripes in the colours red, white, blue, white and red. The image you use has 7 srtips.
2
Using the Spacers and HStack are unnecessary.
I used the following image without the Spacers and HStack, both the +++ button and the flag are aligned in the center.
https://upload.wikimedia.org/wikipedia/commons/a/a9/Flag_of_Thailand.svg
struct TheNiceView: View {
var body: some View {
VStack {
TheButtonView()
Button(action: {}) {
Text("+++++")
.font(.largeTitle)
.foregroundColor(.gray)
.fontWeight(.heavy)
}
}
}
}
struct TheButtonView: View {
let imgSide:CGFloat = 72.0
var body: some View {
Button(action: {}){
Image( "ThaiFlagImg")
.resizable()
.frame(width: imgSide, height: imgSide)
}
}
}

Make Capsule() move when swiping between tabs - matchedGeometryEffect

I'm implementing a swiping tab view using SwiftUI's PageTabViewStyle() tabView style, similar to, say, Reddit mobile. On the top are all the tab names which turn blue, and right under a small Capsule() which moves to the selected tab. I'm having difficulty making the capsule move using matchedGeometryEffect() when the user has gone to a new tab, however.
// The parent view
#State private var selectedTab: Category = .all
// [...]
VStack(spacing: 0) {
HistoryNavigationBar(selectedTab: $selectedTab)
TabView(selection: $selectedTab) {
ForEach(Category.allCases, id: \.self) { category in
HistoryList(category: category)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
}
With the important parts of HistoryNavigationBar being:
#Namespace private var animation
// [...]
ForEach(History.Category.allCases, id: \.self) { category in
VStack(alignment: .leading, spacing: 5) {
Button(action: { selectedTab = category }) {
Text(category.rawValue.capitalized)
.foregroundColor(selectedTab == category ? .blue : .gray)
}
Capsule()
.frame(width: 20, height: 2)
.foregroundColor(selectedTab == category ? .blue : .clear)
}
}
How should I implement matchedGeometryEffect() to get the desired effect of the capsule moving every time the tab is changed? I tried all sorts of combinations with matchedGeometryEffect() (putting it after the capsule, putting the capsule in an if-statement, etc.), but to no success – whenever I swipe between tabs, or click on one of the tab names, the capsule just jumps to that tab.
You need to put the .matchedGeometryEffect on the capsule, but only draw the Capsule when the tab is selected (using an if-statement). You also need to add animation to the Capsule. Here is an example:
struct MGE: View {
#State var selectedTab: String = "one"
#Namespace private var namespace
enum Category: String, CaseIterable {
case one, two, three
}
var body: some View {
HStack(alignment: .top) {
ForEach(Category.allCases, id: \.self) { category in
VStack(alignment: .leading, spacing: 5) {
Button(action: { selectedTab = category.rawValue }) {
Text(category.rawValue.capitalized)
.foregroundColor(selectedTab == category.rawValue ? .blue : .gray)
}
if selectedTab == category.rawValue {
Capsule()
.frame(width: 20, height: 2)
.foregroundColor(.blue)
.matchedGeometryEffect(id: "capsule", in: namespace)
.animation(.spring())
}
}
}
}
}
}

Resources