Make Capsule() move when swiping between tabs - matchedGeometryEffect - ios

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

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

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

SwiftUI Edges not ignored on simulator

I'm a SwiftUI trainee. On this particular view below there is an issue like in the image.
While .ignoreSafeArea(.bottom) or .edgesIgnoreSafeArea(.bottom) works on preview.
It does not work on the simulator. I would like to learn is it a bug or am I missing something. Thanks for your help ahead!
Issue screen shot
Updated Solution
The problem was caused by root view logic. When you use navigationLink to navigate another screen on root view causes this problem. I didnt want to use standard NavigationLink to navigate because it was freezing animations(Lottie) I'm playing on screen when you go to some screen via navigationLink and come back.
Below is the view code. Hopefully not that messy.
import SwiftUI
struct ChatView: View {
// MARK: properties
let userName : String
let userImageUrl : String
#ObservedObject var viewModel : ChatViewModel = ChatViewModel()
#ObservedObject var appState : NavigationController = NavigationController.shared
// MARK: body
var body: some View {
ZStack(alignment: .bottom) {
VStack {
buildNavigationBar()
buildMessages()
} // end of Vstack
.ignoresSafeArea(edges:.bottom)
buildInputRow()
} // end of Zstack
.ignoresSafeArea(edges:.bottom)
}
fileprivate func buildInputRow() -> some View {
return
HStack(alignment: .center){
DynamicHorizontalSpacer(size: 30)
Button {
} label: {
Image(systemName: "photo.circle.fill")
.font(.system(size: 35))
}
DynamicHorizontalSpacer(size: 25)
UnobscuredTextFieldView(textBinding: .constant("Hello"), promptText: "Type!", width: 180, color: .white)
DynamicHorizontalSpacer(size: 25)
Button {} label: {
Image(systemName: "paperplane.fill")
.font(.system(size: 30))
.foregroundColor(.accentColor)
}
Spacer()
} // end of HStack
.frame(width: .infinity, height: 100, alignment: .center)
.background(Color.gray.opacity(0.4).ignoresSafeArea(edges:.bottom))
}
fileprivate func buildMessages() -> some View {
return ScrollView(showsIndicators: false) {
ForEach(0...50, id : \.self) { index in
ChatTileView(index: index)
}
.padding(.horizontal,5)
} // end of scrollview
.ignoresSafeArea(edges:.bottom)
}
fileprivate func buildNavigationBar() -> ChatViewNavigationBar {
return ChatViewNavigationBar(userImageUrl: self.userImageUrl, userName: self.userName) {
appState.appState = .Home
}
}
}
fileprivate func buildMessageBox() -> some View {
return HStack(alignment: .center) {
Text(
"""
Fake message
"""
)
.font(.system(size:11))
.foregroundColor(.white)
}
}
}
fileprivate extension View {
func messageBoxModifier(index : Int) -> some View {
self
.multilineTextAlignment(index.isMultiple(of: 2) ?.trailing : .leading)
.frame(minHeight: 30)
.padding(.vertical,7)
.padding(.horizontal,10)
.background(index.isMultiple(of: 2) ? Color.green : Color.mint)
.cornerRadius(12)
.shadow(color: .black.opacity(0.3), radius: 5, y: 5)
}
}
some components used in eg. DynamicHorizantalSpacer
DynamicHorizontalSpacer && Vertical as well they share same logic
struct DynamicVerticalSpacer: View {
let size : CGFloat?
var body: some View {
Spacer()
.frame(width: 0, height: size ?? 20, alignment: .center)
}
}
TextField that I'm using.
struct UnobscuredTextFieldView: View {
#Binding var textBinding : String
let promptText: String
let width : CGFloat
let color : Color
var body: some View {
TextField(text: $textBinding, prompt: Text(promptText)) {
Text("Email")
}
.textFieldModifier()
.modifier(RoundedTextFieldModifier(color:color ,width: width))
}
}
fileprivate extension TextField {
func textFieldModifier() -> some View {
self
.textCase(.lowercase)
.textSelection(.disabled)
.disableAutocorrection(true)
.textInputAutocapitalization(.never)
.textContentType(.emailAddress)
}
}
The problem was caused by root view logic. When you use navigationLink to navigate another screen on root view causes this problem. I didnt want to use standard NavigationLink to navigate because it was freezing animations I'm playing on screen when you go to some screen via navigationLink and come back.

Is there a way to define the width of my SwiftUI Image to be relative to screen size using Geometry Reader?

I am working through a sample app to familiarize myself with Swift and SwiftUI and am wanting to combine the ideas of composite layouts using custom views and GeometryReader to determine the screen-size of the device.
The page I'm trying to build is a scrollable list of friends where each row item is a button that can be selected to expand the list to see more details of each friend. I've built the button and all containing views as a FriendView to use inside of my FriendListView. I have defined 2 GeometryReader views within the FriendListView but do not know how I can define the size of each row from within the FriendView to make it maintain the appropriate size.
The need for this comes from not every image that could be set by a user later on for their friend will have the same dimensions so I want to keep it consistent. I understand I could set a .frame() modifier and hardcode the width but I would like it to be relational to the screen to be consistent across all phone screens.
Example: When a row is not expanded, the image should take up half of the device width. When it is expanded I would want it to take up one quarter of the device screen width.
Screenshot
Application View
Sample Code
struct FriendListView: View {
var pets = ["Woofer", "Floofer", "Booper"]
var body: some View {
GeometryReader { geo1 in
NavigationView {
GeometryReader { geo2 in
List(self.pets, id: \.self) { pet in
FriendView(name: pet)
}// End of List
}
.navigationBarTitle(Text("Furiends"))
}// End of NavigationView
}// End of GeometryReader geo1
}// End of body
}
struct FriendView: View {
#State private var isExpanded = false
var randomPic = Int.random(in: 1...2)
var name = ""
var breed = "Dawg"
var body: some View {
Button(action: self.toggleExpand) {
HStack {
VStack {
Image("dog\(self.randomPic)")
.resizable()
.renderingMode(.original)
.scaledToFill()
.clipShape(Circle())
if self.isExpanded == false {
Text(self.name)
.font(.title)
.foregroundColor(.primary)
}
}
if self.isExpanded == true {
VStack {
Text(self.name).font(.title)
Text(self.breed).font(.headline)
}
.foregroundColor(.primary)
}
}
}// End of Button
}
func toggleExpand() {
isExpanded.toggle()
}
}
You can define a GeometryReader in the top level FriendListView and pass the screen width to the FriendView:
struct FriendListView: View {
var pets = ["Woofer", "Floofer", "Booper"]
var body: some View {
GeometryReader { geo in
NavigationView {
List(self.pets, id: \.self) { pet in
FriendView(name: pet, screenWidth: geo.size.width)
}
.navigationBarTitle(Text("Furiends"))
}
}
}
}
Then use the .frame to set the maximum width for an image.
struct FriendView: View {
...
var screenWidth: CGFloat
var imageWidth: CGFloat {
isExpanded ? screenWidth / 4 : screenWidth / 2
}
var body: some View {
Button(action: self.toggleExpand) {
HStack {
VStack {
Image("dog\(self.randomPic)")
.resizable()
.renderingMode(.original)
.scaledToFill()
.clipShape(Circle())
.frame(width: imageWidth, height: imageWidth) // <- add frame boundaries
if self.isExpanded == false {
Text(self.name)
.font(.title)
.foregroundColor(.primary)
}
}
if self.isExpanded == true {
VStack {
Text(self.name).font(.title)
Text(self.breed).font(.headline)
}
.foregroundColor(.primary)
}
}
}
}
func toggleExpand() {
isExpanded.toggle()
}
}
In case you'd want your picture to be centered you can use Spacer:
HStack {
Spacer()
FriendView(name: pet, screenWidth: geo.size.width)
Spacer()
}

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.

Resources