Odd animation with .matchedGeometryEffect and NavigationView - ios

I'm working on creating a view which uses matchedGeometryEffect, embedded within a NavigationView, and when presenting the second view another NavigationView.
The animation "works" and it matches correctly, however when it toggles from view to view it happens to swing as if unwinding from the navigation stack.
However, if I comment out the NavigationView on the secondary view the matched geometry works correctly.
I am working on iOS 14.0 and above.
Sample code
Model / Mock Data
struct Model: Identifiable {
let id = UUID().uuidString
let icon: String
let title: String
let account: String
let colour: Color
}
let mockItems: [Model] = [
Model(title: "Test title 1", colour: .gray),
Model(title: "Test title 2", colour: .blue),
Model(title: "Test title 3", colour: .purple)
...
]
Card View
struct CardView: View {
let item: Model
var body: some View {
VStack {
Text(item.title)
.font(.title3)
.fontWeight(.heavy)
}
.padding()
.frame(maxWidth: .infinity, minHeight: 100, alignment: .leading)
.background(item.colour)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
Secondary / Detail View
struct DetailView: View {
#Binding var isShowingDetail: Bool
let item: Model
let animation: Namespace.ID
var body: some View {
NavigationView { // <--- comment out here and it works
VStack {
CardView(item: item)
.matchedGeometryEffect(id: item.id, in: animation)
.onTapGesture {
withAnimation { isShowingDetail = false }
}
ScrollView(.vertical, showsIndicators: false) {
Text("Lorem ipsum dolor...")
}
}
.padding(.horizontal)
.navigationBarTitleDisplayMode(.inline)
}
.navigationViewStyle(.stack)
}
}
Primary View
struct ListView: View {
#State private var selectedCard: Model?
#State private var isShowingCard: Bool = false
#Namespace var animation
var body: some View {
ZStack {
NavigationView {
ScrollView(.vertical, showsIndicators: false) {
ForEach(mockItems) { item in
CardView(item: item)
.matchedGeometryEffect(id: item.id, in: animation)
.onTapGesture {
withAnimation {
selectedCard = item
isShowingCard = true
}
}
}
}
.navigationTitle("Test title")
}
.padding(.horizontal)
.navigationViewStyle(.stack)
// show detail view
if let selectedCard = selectedCard, isShowingCard {
DetailView(
isShowingDetail: $isShowingCard,
item: selectedCard,
animation: animation
)
}
}
}
}
Video examples
With NavigationView in DetailView
Without NavigationView in DetailView
Ignore the list view still visible

You don't need second NavigationView (actually I don't see links at all, so necessity of the first one is also under question). Anyway we can just change the layout order and put everything into one NavigationView, like below.
Tested with Xcode 13.4 / iOS 15.5
struct ListView: View {
#State private var selectedCard: Model?
#State private var isShowingCard: Bool = false
#Namespace var animation
var body: some View {
NavigationView {
ZStack { // container !!
if let selectedCard = selectedCard, isShowingCard {
DetailView(
isShowingDetail: $isShowingCard,
item: selectedCard,
animation: animation
)
} else {
ScrollView(.vertical, showsIndicators: false) {
ForEach(mockItems) { item in
CardView(item: item)
.matchedGeometryEffect(id: item.id, in: animation)
.onTapGesture {
selectedCard = item
isShowingCard = true
}
}
}
.navigationTitle("Test title")
}
}
.padding(.horizontal)
.navigationViewStyle(.stack)
.animation(.default, value: isShowingCard) // << animated here !!
}
}
}
struct DetailView: View {
#Binding var isShowingDetail: Bool
let item: Model
let animation: Namespace.ID
var body: some View {
VStack {
CardView(item: item)
.matchedGeometryEffect(id: item.id, in: animation)
.onTapGesture {
isShowingDetail = false
}
ScrollView(.vertical, showsIndicators: false) {
Text("Lorem ipsum dolor...")
}
}
.padding(.horizontal)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
}
}
Test module is here

Related

How to make each item clickable to show detailed view in LazyVGrid?

//
// ContentView.swift
// DemoProject
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
private var gridItemLayout = [GridItem(.adaptive(minimum: 100))]
#StateObject private var viewModel = HomeViewModel()
var body: some View {
GeometryReader { geometry in
NavigationView {
ScrollView {
LazyVGrid(columns: gridItemLayout, spacing: 20) {
ForEach(viewModel.results, id: \.self) {
let viewModel = ResultVM(model: $0)
NavigationLink(destination: {
DetailView()
}, label: {
SearchResultRow(resultVM: viewModel)
})
}
}
}
}
.onAppear(perform: {
viewModel.performSearch()
})
}
}
}
struct SearchResultRow: View {
let resultVM: ResultVM
var body: some View {
HStack {
RoundedRectangle(cornerRadius: 16).fill(.yellow)
.frame(maxWidth: .infinity).aspectRatio(1, contentMode: .fit)
.overlay(Text(resultVM.trackName))
.onTapGesture {
}
}.padding()
.background(Color.red)
}
}
I want to navigate to detailed view on click on each Cell, I tried navigation but its not clicking.
DetailView
import SwiftUI
struct DetailView: View {
var body: some View {
Text("Hello World")
}
}
To make your sub grid clickable and leads to another view try this:
ForEach(viewModel.results, id: \.self) {
let viewModel = ResultVM(model: $0)
NavigationLink {
//your DetailView()
} label: {
//this is the design for your navigation button
SearchResultRow(resultVM: viewModel)
}
}
The tap gesture blocks NavigationLink (which is actually a button, so also works onTap), so the fix is just remove
var body: some View {
HStack {
RoundedRectangle(cornerRadius: 16).fill(.yellow)
.frame(maxWidth: .infinity).aspectRatio(1, contentMode: .fit)
.overlay(Text(resultVM.trackName))
// .onTapGesture { // << this conflict !!
// }
Tested with Xcode 13.4 / iOS 15.5
*Note: if you need both, then use .simultaneousGesture(TapGesture().onEnded {})

SwiftUI - View disappears if animated

I am building a custom segmented control. This is the code that I have written.
struct SegmentedControl: View {
private var items: [String] = ["One", "Two", "Three"]
#Namespace var animation:Namespace.ID
#State var selected: String = "One"
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(items, id: \.self) { item in
Button(action: {
withAnimation(.spring()){
self.selected = item
}
}) {
Text(item)
.font(Font.subheadline.weight(.medium))
.foregroundColor(selected == item ? .white : .accentColor)
.padding(.horizontal, 25)
.padding(.vertical, 10)
.background(zStack(item: item))
}
}
} .padding()
}
}
private func zStack(item: String) -> some View {
ZStack{
if selected == item {
Color.accentColor
.clipShape(Capsule())
.matchedGeometryEffect(id: "Tab", in: animation)
} else {
Color(.gray)
.clipShape(Capsule())
}}
}
}
A control is Blue when it is selected.
It works as expected most of the time like in the following GIF.
However, sometimes if you navigate back and forth very fast, the Color.accentColor moves off screen and disappears as you see in the following GIF. I have used a lot of time but could not fix it.
Sometimes, I get this error.
Multiple inserted views in matched geometry group Pair<String,
ID>(first: "Tab", second: SwiftUI.Namespace.ID(id: 248)) have `isSource:
true`, results are undefined.
Info, It is easier to test it on a physical device rather than a simulator.
Update
This is my all codde including the ContentView and the Modal.
struct ContentView: View {
#State private var isPresented: Bool = false
var body: some View {
NavigationView {
VStack {
Button(action: {
self.isPresented.toggle()
}, label: {
Text("Button")
})
}
}
.sheet(isPresented: $isPresented, content: {
ModalView()
})
}
}
struct ModalView: View {
var body: some View {
NavigationView {
NavigationLink(
destination: TabbarView(),
label: {
Text("Navigate")
})
}
}
}
struct TabbarView: View {
private var items: [String] = ["One", "Two", "Three"]
#Namespace var animation:Namespace.ID
#State var selected: String = "" // change here
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(items, id: \.self) { item in
Button(action: {
withAnimation{
self.selected = item
}
}) {
Text(item)
.font(Font.subheadline.weight(.medium))
.foregroundColor(selected == item ? .white : .accentColor)
.padding(.horizontal, 25)
.padding(.vertical, 10)
.background(zStack(item: item))
}
}
} .padding()
}
.onAppear { self.selected = "One" } // add this
}
private func zStack(item: String) -> some View {
ZStack{
if selected == item {
Color.accentColor
.clipShape(Capsule())
.matchedGeometryEffect(id: "Tab", in: animation)
} else {
Color(.gray)
.clipShape(Capsule())
}}
}
}

SwiftUI setting individual backgrounds for list Elements that fill the element

So I'm trying to make a list where the background of each element is an image that completely fills the element and I'm struggling to get it to completely fill the area.
here's some simple example code showing what I have so far.
struct Event {
var description:String = "description"
var title:String = "Title"
var view:Image
}
struct RowView: View {
#State var event:Event
var body: some View {
VStack {
Text(event.title)
.font(.headline)
Text(event.description)
.font(.subheadline)
}
}
}
struct ContentView: View {
#State var events:[Event] = [Event(view: Image("blue")),Event( view: Image("red")),Event( view: Image("green"))]
#State private var editMode = EditMode.inactive
var body: some View {
NavigationView {
List() {
ForEach(events.indices, id: \.self) { elementID in
NavigationLink(
destination: RowView(event: events[elementID])) {
RowView(event: events[elementID])
}
.background(events[elementID].view)
.clipped()
}
}
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.navigationTitle("Events")
.navigationBarItems(leading: EditButton())
.environment(\.editMode, $editMode)
}
}
}
This is what's produced by the code as you can see there is a border around the background image still.
I don't know what your images are, but seems that should not be important. Here is a demo on replicated code using colors.
Tested with Xcode 12.1 / iOS 14.1
Modified parts (important highlighted in comments)
struct Event {
var description:String = "description"
var title:String = "Title"
var view:Color
}
struct ContentView: View {
#State var events:[Event] = [Event(view: .blue),Event( view: .red),Event( view: .green)]
#State private var editMode = EditMode.inactive
var body: some View {
NavigationView {
List() {
ForEach(events.indices, id: \.self) { elementID in
events[elementID].view.overlay( // << this !!
NavigationLink(
destination: RowView(event: events[elementID])) {
RowView(event: events[elementID])
})
}
.listRowInsets(EdgeInsets()) // << this !!
}
.listStyle(PlainListStyle()) // << and this !!
.navigationTitle("Events")
.navigationBarItems(leading: EditButton())
.environment(\.editMode, $editMode)
}
}
}
This works:
struct RowView: View {
#State var event:Event
var body: some View {
VStack(alignment: .leading) {
Text(event.title)
.font(.headline)
Text(event.description)
.font(.subheadline)
}
.padding(10)
.foregroundColor(.yellow)
}
}
struct ContentView: View {
#State var events:[Event] = [Event(view: Image("blue")),Event( view: Image("red")),Event( view: Image("green"))]
#State private var editMode = EditMode.inactive
var body: some View {
NavigationView {
List() {
ForEach(events.indices, id: \.self) { elementID in
NavigationLink(
destination: RowView(event: events[elementID])) {
RowView(event: events[elementID])
}
.background(events[elementID].view
.resizable()
.aspectRatio(contentMode: .fill)
)
.clipped()
}
.listRowInsets(EdgeInsets())
}
.listStyle(PlainListStyle())
.navigationTitle("Events")
.navigationBarItems(leading: EditButton())
.environment(\.editMode, $editMode)
}
}
}
This is a derivative of Asperi's answer, so you can accept his answer, but this works with images, and no overlay. Tested on Xcode 12.1, IOS 14.1.
Here is an image where I grabbed arbitrary sized images for "red", "green", and "blue" from Goggle. I took the liberty of changing the row text to yellow so it would show up better.

SwiftUI Hide TabView bar inside NavigationLink views

I have a TabView and separate NavigationView stacks for every Tab item. It works well but when I open any NavigationLink the TabView bar is still displayed. I'd like it to disappear whenever I click on any NavigationLink.
struct MainView: View {
#State private var tabSelection = 0
var body: some View {
TabView(selection: $tabSelection) {
FirstView()
.tabItem {
Text("1")
}
.tag(0)
SecondView()
.tabItem {
Text("2")
}
.tag(1)
}
}
}
struct FirstView: View {
var body: some View {
NavigationView {
NavigationLink(destination: FirstChildView()) { // How can I open FirstViewChild with the TabView bar hidden?
Text("Go to...")
}
.navigationBarTitle("FirstTitle", displayMode: .inline)
}
}
}
I found a solution to put a TabView inside a NavigationView, so then after I click on a NavigationLink the TabView bar is hidden. But this messes up NavigationBarTitles for Tab items.
struct MainView: View {
#State private var tabSelection = 0
var body: some View {
NavigationView {
TabView(selection: $tabSelection) {
...
}
}
}
}
struct FirstView: View {
var body: some View {
NavigationView {
NavigationLink(destination: FirstChildView()) {
Text("Go to...")
}
.navigationBarTitle("FirstTitle", displayMode: .inline) // This will not work now
}
}
}
With this solution the only way to have different NavigationTabBars per TabView item, is to use nested NavigationViews. Maybe there is a way to implement nested NavigationViews correctly? (As far as I know there should be only one NavigationView in Navigation hierarchy).
How can I hide TabView bar inside NavigationLink views correctly in SwiftUI?
I really enjoyed the solutions posted above, but I don't like the fact that the TabBar is not hiding according to the view transition.
In practice, when you swipe left to navigate back when using tabBar.isHidden, the result is not acceptable.
I decided to give up the native SwiftUI TabView and code my own.
The result is more beautiful in the UI:
Here is the code used to reach this result:
First, define some views:
struct FirstView: View {
var body: some View {
NavigationView {
VStack {
Text("First View")
.font(.headline)
}
.navigationTitle("First title")
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
.background(Color.yellow)
}
}
}
struct SecondView: View {
var body: some View {
VStack {
NavigationLink(destination: ThirdView()) {
Text("Second View, tap to navigate")
.font(.headline)
}
}
.navigationTitle("Second title")
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
.background(Color.orange)
}
}
struct ThirdView: View {
var body: some View {
VStack {
Text("Third View with tabBar hidden")
.font(.headline)
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
.background(Color.red.edgesIgnoringSafeArea(.bottom))
}
}
Then, create the TabBarView (which will be the root view used in your app):
struct TabBarView: View {
enum Tab: Int {
case first, second
}
#State private var selectedTab = Tab.first
var body: some View {
VStack(spacing: 0) {
ZStack {
if selectedTab == .first {
FirstView()
}
else if selectedTab == .second {
NavigationView {
VStack(spacing: 0) {
SecondView()
tabBarView
}
}
}
}
.animation(nil)
if selectedTab != .second {
tabBarView
}
}
}
var tabBarView: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 20) {
tabBarItem(.first, title: "First", icon: "hare", selectedIcon: "hare.fill")
tabBarItem(.second, title: "Second", icon: "tortoise", selectedIcon: "tortoise.fill")
}
.padding(.top, 8)
}
.frame(height: 50)
.background(Color.white.edgesIgnoringSafeArea(.all))
}
func tabBarItem(_ tab: Tab, title: String, icon: String, selectedIcon: String) -> some View {
ZStack(alignment: .topTrailing) {
VStack(spacing: 3) {
VStack {
Image(systemName: (selectedTab == tab ? selectedIcon : icon))
.font(.system(size: 24))
.foregroundColor(selectedTab == tab ? .primary : .black)
}
.frame(width: 55, height: 28)
Text(title)
.font(.system(size: 11))
.foregroundColor(selectedTab == tab ? .primary : .black)
}
}
.frame(width: 65, height: 42)
.onTapGesture {
selectedTab = tab
}
}
}
This solution also allows a lot of customization in the TabBar.
You can add some notifications badges, for example.
If we talk about standard TabView, the possible workaround solution can be based on TabBarAccessor from my answer on Programmatically detect Tab Bar or TabView height in SwiftUI
Here is a required modification in tab item holding NavigationView. Tested with Xcode 11.4 / iOS 13.4
struct FirstTabView: View {
#State private var tabBar: UITabBar! = nil
var body: some View {
NavigationView {
NavigationLink(destination:
FirstChildView()
.onAppear { self.tabBar.isHidden = true } // !!
.onDisappear { self.tabBar.isHidden = false } // !!
) {
Text("Go to...")
}
.navigationBarTitle("FirstTitle", displayMode: .inline)
}
.background(TabBarAccessor { tabbar in // << here !!
self.tabBar = tabbar
})
}
}
Note: or course if FirstTabView should be reusable and can be instantiated standalone, then tabBar property inside should be made optional and handle ansbsent tabBar explicitly.
Thanks to another Asperi's answer I was able to find a solution which does not break animations and looks natural.
struct ContentView: View {
#State private var tabSelection = 1
var body: some View {
NavigationView {
TabView(selection: $tabSelection) {
FirstView()
.tabItem {
Text("1")
}
.tag(1)
SecondView()
.tabItem {
Text("2")
}
.tag(2)
}
// global, for all child views
.navigationBarTitle(Text(navigationBarTitle), displayMode: .inline)
.navigationBarHidden(navigationBarHidden)
.navigationBarItems(leading: navigationBarLeadingItems, trailing: navigationBarTrailingItems)
}
}
}
struct FirstView: View {
var body: some View {
NavigationLink(destination: Text("Some detail link")) {
Text("Go to...")
}
}
}
struct SecondView: View {
var body: some View {
Text("We are in the SecondView")
}
}
Compute navigationBarTitle and navigationBarItems dynamically:
private extension ContentView {
var navigationBarTitle: String {
tabSelection == 1 ? "FirstView" : "SecondView"
}
var navigationBarHidden: Bool {
tabSelection == 3
}
#ViewBuilder
var navigationBarLeadingItems: some View {
if tabSelection == 1 {
Text("+")
}
}
#ViewBuilder
var navigationBarTrailingItems: some View {
if tabSelection == 1 {
Text("-")
}
}
}
How about,
struct TabSelectionView: View {
#State private var currentTab: Tab = .Scan
private enum Tab: String {
case Scan, Validate, Settings
}
var body: some View {
TabView(selection: $currentTab){
ScanView()
.tabItem {
Label(Tab.Scan.rawValue, systemImage: "square.and.pencil")
}
.tag(Tab.Scan)
ValidateView()
.tabItem {
Label(Tab.Validate.rawValue, systemImage: "list.dash")
}
.tag(Tab.Validate)
SettingsView()
.tabItem {
Label(Tab.Settings.rawValue, systemImage: "list.dash")
}
.tag(Tab.Settings)
}
.navigationBarTitle(Text(currentTab.rawValue), displayMode: .inline)
}
}
I also faced this problem. I don't want to rewrite, but the solution is in my github. I wrote everything in detail there
https://github.com/BrotskyS/AdvancedNavigationWithTabView
P.S: I have no reputation to write comments. Hikeland's solution is not bad. But you do not save the State of the page. If you have a ScrollView, it will reset to zero every time when you change tab
Also you can create very similar custom navBar for views in TabView
struct CustomNavBarView<Content>: View where Content: View {
var title: String = ""
let content: Content
init(title: String, #ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
content
.safeAreaInset(edge: .top, content: {
HStack{
Spacer()
Text(title)
.fontWeight(.semibold)
Spacer()
}
.padding(.bottom, 10)
.frame(height: 40)
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.overlay {
Divider()
.frame(maxHeight: .infinity, alignment: .bottom)
}
})
}
}
CustomNavBarView(title: "Create ad"){
ZStack{
NavigationLink(destination: SetPinMapView(currentRegion: $vm.region, region: vm.region), isActive: $vm.showFullMap) {
Color.clear
}
Color("Background").ignoresSafeArea()
content
}
}

SwiftUI How to push to next screen when tapping on Button

I can navigate to next screen by using NavigationButton (push) or present with PresentationButton (present) but i want to push when i tap on Buttton()
Button(action: {
// move to next screen
}) {
Text("See More")
}
is there a way to do it?
You can do using NavigationLink
Note: Please try in real device. in simulator sometimes not work properly.
struct MasterView: View {
#State var selection: Int? = nil
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailsView(), tag: 1, selection: $selection) {
Button("Press me") {
self.selection = 1
}
}
}
}
}
}
struct DetailsView: View {
#Environment(\.presentationMode) var presentation
var body: some View {
Group {
Button("Go Back") {
self.presentation.wrappedValue.dismiss()
}
}
}
}
As you can see to display the new view, add the NavigationLink with isActive: $pushView using <.hidden()> to hide the navigation "arrow".
Next add Text("See More") with tapGesture to make the text respond to taps. The variable pushView will change (false => true) when you click "See More" text.
import SwiftUI
struct ContentView: View {
#State var pushView = false
var body: some View {
NavigationView {
List {
HStack{
Text("test")
Spacer()
NavigationLink(destination: NewView(), isActive: $pushView) {
Text("")
}.hidden()
.navigationBarTitle(self.pushView ? "New view" : "default view")
Text("See More")
.padding(.trailing)
.foregroundColor(Color.blue)
.onTapGesture {
self.pushView.toggle()
}
}
}
}
}
}
struct NewView: View {
var body: some View {
Text("New View")
}
}
ContentView picture
NewView picture
To tap on button and navigate to next screen,You can use NavigationLink like below
NavigationView{
NavigationLink(destination: SecondView()) {
Text("Login")
.padding(.all, 5)
.frame(minWidth: 0, maxWidth: .infinity,maxHeight: 45, alignment: .center)
.foregroundColor(Color.white)
}
}
You can use NavigationLink to implement this:
struct DetailsView: View {
var body: some View {
VStack {
Text("Hello world")
}
}
}
struct ContentView: View {
#State var selection: Int? = nil
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailsView(), tag: 1, selection: $selection) {
Button("Press me") {
self.selection = 1
}
}
}
}
}
}

Resources