Custom modal transitions in SwiftUI - ios

I'm trying to recreate the iOS 11/12 App Store with SwiftUI.
Let's imagine the "story" is the view displayed when tapping on the card.
I've done the cards, but the problem I'm having now is how to do the animation done to display the "story".
As I'm not good at explaining, here you have a gif:
Gif 1
Gif 2
I've thought of making the whole card a PresentationLink, but the "story" is displayed as a modal, so it doesn't cover the whole screen and doesn't do the animation I want.
The most similar thing would be NavigationLink, but that then obliges me to add a NavigationView, and the card is displayed like another page.
I actually do not care whether its a PresentationLink or NavigationLink or whatever as long as it does the animation and displays the "story".
Thanks in advance.
My code:
Card.swift
struct Card: View {
var icon: UIImage = UIImage(named: "flappy")!
var cardTitle: String = "Welcome to \nCards!"
var cardSubtitle: String = ""
var itemTitle: String = "Flappy Bird"
var itemSubtitle: String = "Flap That!"
var cardCategory: String = ""
var textColor: UIColor = UIColor.white
var background: String = ""
var titleColor: Color = .black
var backgroundColor: Color = .white
var body: some View {
VStack {
if background != "" {
Image(background)
.resizable()
.frame(width: 380, height: 400)
.cornerRadius(20)
} else {
RoundedRectangle(cornerRadius: 20)
.frame(width: 400, height: 400)
.foregroundColor(backgroundColor)
}
VStack {
HStack {
VStack(alignment: .leading) {
if cardCategory != "" {
Text(verbatim: cardCategory.uppercased())
.font(.headline)
.fontWeight(.heavy)
.opacity(0.3)
.foregroundColor(titleColor)
//.opacity(1)
}
HStack {
Text(verbatim: cardTitle)
.font(.largeTitle)
.fontWeight(.heavy)
.lineLimit(3)
.foregroundColor(titleColor)
}
}
Spacer()
}.offset(y: -390)
.padding(.bottom, -390)
HStack {
if cardSubtitle != "" {
Text(verbatim: cardSubtitle)
.font(.system(size: 17))
.foregroundColor(titleColor)
}
Spacer()
}
.offset(y: -50)
.padding(.bottom, -50)
}
.padding(.leading)
}.padding(.leading).padding(.trailing)
}
}
So
Card(cardSubtitle: "Welcome to this library I made :p", cardCategory: "CONNECT", background: "flBackground", titleColor: .white)
displays:

SwiftUI doesn't do custom modal transitions right now, so we have to use a workaround.
One method that I could think of is to do the presentation yourself using a ZStack. The source frame could be obtained using a GeometryReader. Then, the destination shape could be controlled using frame and position modifiers.
In the beginning, the destination will be set to exactly match position and size of the source. Then immediately afterwards, the destination will be set to fullscreen size in an animation block.
struct ContentView: View {
#State var isPresenting = false
#State var isFullscreen = false
#State var sourceRect: CGRect? = nil
var body: some View {
ZStack {
GeometryReader { proxy in
Button(action: {
self.isFullscreen = false
self.isPresenting = true
self.sourceRect = proxy.frame(in: .global)
}) { ... }
}
if isPresenting {
GeometryReader { proxy in
ModalView()
.frame(
width: self.isFullscreen ? nil : self.sourceRect?.width ?? nil,
height: self.isFullscreen ? nil : self.sourceRect?.height ?? nil)
.position(
self.isFullscreen ? proxy.frame(in: .global).center :
self.sourceRect?.center ?? proxy.frame(in: .global).center)
.onAppear {
withAnimation {
self.isFullscreen = true
}
}
}
}
}
.edgesIgnoringSafeArea(.all)
}
}
extension CGRect {
var center : CGPoint {
return CGPoint(x:self.midX, y:self.midY)
}
}

SwiftUI in iOS/tvOS 14 and macOS 11 has matchedGeometryEffect(id:in:properties:anchor:isSource:) to animate view transitions between different hierarchies.
Link to Official Documentation
Here's a minimal example:
struct SomeView: View {
#State var isPresented = false
#Namespace var namespace
var body: some View {
VStack {
Button(action: {
withAnimation {
self.isPresented.toggle()
}
}) {
Text("Toggle")
}
SomeSourceContainer {
MatchedView()
.matchedGeometryEffect(id: "UniqueViewID", in: namespace, properties: .frame, isSource: !isPresented)
}
if isPresented {
SomeTargetContainer {
MatchedTargetView()
.matchedGeometryEffect(id: "UniqueViewID", in: namespace, properties: .frame, isSource: isPresented)
}
}
}
}
}

Related

Data loss on View Class, when keyboard appears

When ever I click on textField and key board appears, my list of data i.e coming from API is vanashing,
import SwiftUI
import ExytePopupView
struct Wallet: View {
#State private var searchText = ""
#State private var showCancelButton: Bool = false
#ObservedObject var walltetVM = ShopViewModel()
#State var showShopDetails: Bool = false
#State var openShowDetails: Bool = false
#State var selectedShopId:String = ""
#State var selectedCouponDetail: CouponModel?
var body: some View {
NavigationView {
VStack {
// Search view
VStack{
HStack {
Button {
} label: {
Image(systemName: "magnifyingglass")
.foregroundColor(AppColors.semiBlue)
.frame(width: 20, height: 20, alignment: .center)
.padding()
}
ZStack(alignment: .leading) {
if searchText.isEmpty {
Text(MaggnetLocalizedString(key: "Restaurant, Beauty shop...", comment: ""))
.foregroundColor(AppColors.blackWhite)
.font(Font(AppFont.lightFont(lightFontWithSize: 15)))
}
TextField("", text: $searchText)
.font(Font(AppFont.regularFont(regularFontWithSize: 15)))
}
.foregroundColor(AppColors.blackWhite)
.padding(.horizontal,10)
.padding(.leading,-15)
Divider()
Button {
} label: {
HStack {
Image("places")
}
.padding(.horizontal,20)
.padding(.leading,-12)
}
}
.frame(height: 45)
.background(AppColors.fadeBackground)
.clipShape(Capsule())
}
.padding(.horizontal)
.padding(.vertical,10)
ScrollView(.vertical) {
VStack{
NavigationLink(destination:ShopDetail(shopId:self.selectedShopId)
.environmentObject(walltetVM),
isActive: $openShowDetails) {
EmptyView()
}.hidden()
Points()
ForEach(0..<walltetVM.finalsCouponList.count,id: \.self){
index in
VStack{
// SHOP LIST HEADERS
HStack{
Text(walltetVM.finalsCouponList[index].name)
.multilineTextAlignment(.leading)
.font(Font(AppFont.mediumFont(mediumFontWithSize: 15)))
.foregroundColor(AppColors.blackWhite)
.padding(.horizontal,10)
Spacer()
Button {
} label: {
Text(MaggnetLocalizedString(key: "viewAll", comment: ""))
.font(Font(AppFont.regularFont(regularFontWithSize: 12)))
.foregroundColor(AppColors.blackWhite)
.padding()
.frame(height: 27, alignment: .center)
.background(AppColors.fadeBackground)
.cornerRadius(8)
}
}
.padding()
// MAIN SHOP LIST
VStack{
ScrollView(.horizontal,showsIndicators: false){
HStack{
ForEach(0..<walltetVM.finalsCouponList[index].couopons.count,id: \.self){
indeX in
Shops(coupons: walltetVM.finalsCouponList[index].couopons[indeX])
.onTapGesture {
selectedShopId = walltetVM.finalsCouponList[index].couopons[indeX].businessId?.description ?? ""
print(selectedShopId)
selectedCouponDetail = walltetVM.finalsCouponList[index].couopons[indeX]
showShopDetails = true
}
}
}
.padding(.horizontal)
}
}
.padding(.top,-5)
}
.padding(.top,-5)
}
}
}
.blur(radius: showShopDetails ? 3 : 0)
.popup(isPresented: $showShopDetails, autohideIn: 15, dismissCallback: {
showShopDetails = false
}) {
ShopDetailPopUp(couponDeatil: self.selectedCouponDetail)
.frame(width: 300, height: 400)
}
.navigationBarTitle(Text("Wallet"),displayMode: .inline)
.navigationBarItems(trailing: HStack{
Button {
} label: {
Image("wishIcon").frame(width: 20, height: 20, alignment: .center)
}
Button {
} label: {
Image("notifIcon").frame(width: 20, height: 20, alignment: .center)
}
})
.resignKeyboardOnDragGesture()
}
.onAppear(){
walltetVM.getWallets()
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.openShopDetails))
{ obj in
showShopDetails = false
openShowDetails.toggle()
}
.environment(\.layoutDirection,Preferences.chooseLanguage == AppConstants.arabic ? .rightToLeft : .leftToRight)
}
}
}
struct Wallet_Previews: PreviewProvider {
static var previews: some View {
Wallet()
}
}
bannerList is marked as #Published, API call working fine, but in same View class I have one search text field , when I tap on it all the data I was rendering from API get lost and disappears from list.
You are holding a reference to your view model using ObservedObject:
#ObservedObject var walltetVM = ShopViewModel()
However, because you are creating the view model within the view, the view might tear down and recreate this at any moment, which might be why you are losing your data periodically.
If you use StateObject, this ensures that SwiftUI will retain the object for as long as the view lives, so the object won't be recreated.
#StateObject var walltetVM = ShopViewModel()
Alternatively, you could create the view model outside of the view and inject it into the view and keep using ObservedObject (but you'll still need to make sure the object lives as long as the view).
I found something working but still not optimised solution
instead of calling it on onAppear, I called API function in init method.
init(){
walltetVM.getWallets()
}

Swift List row background color not changing

I am working on minimizable side menu with collapsable sub menus in swift. Currently both the minimizing and collapsable submenu components are working. I have spent all evening trying to set the background color of the menu items. As it stands listRowBackground does nothing. I have come across other stack overflow (and other websites) posts talking about this. Some say to use for each loops. Below I have another SideMenu struct using a for each loop. This loop moves the side menu to the center of the screen. Can someone help me with either of these approaches to keep the menu on the left hand side of the screen and change the row background color?
Any help is appreciated!
Edit: I have also updated Xcode to version 14, but it did not help.
import SwiftUI
struct SideMenu: View {
let width: CGFloat
#Binding var menuOpened: Bool
var items: [Menu] = [.item0, .a, .b, .c]
var body: some View {
ZStack {
VStack(alignment: .leading){
HStack{
List(items, children: \.items){ row in
HStack{
if row.imageName != ""{
let image = Image(systemName: row.imageName)
image.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(.black)
.frame(width: 32, height: 32, alignment: .center)
}else{
Text(row.name).listRowBackground(Color.orange)
}
}.listRowBackground(Color.orange)
.onTapGesture {
if row.imageName == "line.horizontal.3"{
menuOpened.toggle()
}
}
}
.background(Color.red)
.listStyle(.plain)
.frame(width: menuOpened ? 275 : 80)
Spacer()
}
}
}
}
}
struct Menu: Identifiable{
let id = UUID()
var name = ""
var imageName = ""
var items: [Menu]?
}
struct ContentView: View {
#State var menuOpened = true
var body: some View {
SideMenu(width: UIScreen.main.bounds.width/1.6, menuOpened: $menuOpened)
}
}
extension Menu{
static var item1 = Menu(name: "a1")
static var item2 = Menu(name: "a2")
static var item3 = Menu(name: "a3")
static var item0 = Menu(imageName: "line.horizontal.3")
static var a = Menu(name: "a", items: [.item1, .item2, item3])
static var b = Menu(name: "b")
static var c = Menu(name: "Settings")
}
Here is a struct with a for each loop
struct SideMenu: View {
let width: CGFloat
#Binding var menuOpened: Bool
var items: [Menu] = [.item0, .a, .b, .c]
var body: some View {
ZStack {
VStack(alignment: .leading){
HStack{
List {
ForEach(items) { row in
HStack{
if row.imageName != ""{
let image = Image(systemName: row.imageName)
image.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(.black)
.frame(width: 32, height: 32, alignment: .center)
}else{
Text(row.name).listRowBackground(Color.orange)
}
}.listRowBackground(Color.orange)
.onTapGesture {
if row.imageName == "line.horizontal.3"{
menuOpened.toggle()
}
}
}
.background(Color.red)
.listStyle(.plain)
.frame(width: menuOpened ? 275 : 80)
Spacer()
}
}
}
}
}
}

SwiftUI: matchedGeometryEffect With Nested Views

I'm facing some dilemma. I like to separate my views for readability . so for example i'm having this kind of structure
MainView ->
--List1
----Items1
--List2
----Items2
----DetailView
------CellView
so cellView having same namespace for matchedGeometryEffect as DetailsView. to make the effect of transition to detail view from cell item in list. the problem is that this details view is limited to List2 screen /View.
Here's some code to make it more clear
First I have main View
struct StartFeedView: View {
var body: some View {
ScrollView(.vertical) {
ShortCutView()
PopularMoviesView()
}
}
}
then I have PopularMoviesView()
struct PopularMoviesView: View {
#Namespace var namespace
#ObservedObject var viewModel = ViewModel()
#State var showDetails: Bool = false
#State var selectedMovie: Movie?
var body: some View {
ZStack {
if !showDetails {
VStack {
HStack {
Text("Popular")
.font(Font.itemCaption)
.padding()
Spacer()
Image(systemName: "arrow.forward")
.font(Font.title.weight(.medium))
.padding()
}
ScrollView(.horizontal) {
if let movies = viewModel.popularMovies {
HStack {
ForEach(movies.results, id: \.id) { movie in
MovieCell(movie: movie, namespace: namespace, image: viewModel.imageDictionary["\(movie.id)"]!)
.padding(6)
.frame(width: 200, height: 300)
.onTapGesture {
self.selectedMovie = movie
withAnimation(Animation.interpolatingSpring(stiffness: 270, damping: 15)) {
showDetails.toggle()
}
}
}
}
}
}
.onAppear {
viewModel.getMovies()
}
}
}
if showDetails, let movie = selectedMovie, let details = movie.details {
MovieDetailsView(details: details, namespace: namespace, image: viewModel.imageDictionary["\(movie.id)"]!)
.onTapGesture {
withAnimation(Animation.interpolatingSpring(stiffness: 270, damping: 15)) {
showDetails.toggle()
}
}
}
}
}
}
so whenever I click on MovieCell.. it will expand to the limit of the PopularMoviesView boundaries on the main view.
Is there some kind of way to make it full screen without to have to inject the detail view into the MainView? Cause that would be really dirty
Here is an approach:
Get the size of MainView with GeometryReader and pass it down.
in DetailView use .overlay which can grow bigger than its parent view,
if you specify an explicit .frame
You need another inner GeometryReaderto get the top pos of inner view for offset.
struct ContentView: View {
var body: some View {
// get size of overall view
GeometryReader { geo in
ScrollView(.vertical) {
Text("ShortCutView()")
PopularMoviesView(geo: geo)
}
}
}
}
struct PopularMoviesView: View {
// passed in geometry from parent view
var geo: GeometryProxy
// own view's top position, will be updated by GeometryReader further down
#State var ownTop = CGFloat.zero
#Namespace var namespace
#State var showDetails: Bool = false
#State var selectedMovie: Int?
var body: some View {
if !showDetails {
VStack {
HStack {
Text("Popular")
.font(.caption)
.padding()
Spacer()
Image(systemName: "arrow.forward")
.font(Font.title.weight(.medium))
.padding()
}
ScrollView(.horizontal) {
HStack {
ForEach(0..<10, id: \.self) { movie in
Text("MovieCell \(movie)")
.padding()
.matchedGeometryEffect(id: movie, in: namespace)
.frame(width: 200, height: 300)
.background(.yellow)
.onTapGesture {
self.selectedMovie = movie
withAnimation(Animation.interpolatingSpring(stiffness: 270, damping: 15)) {
showDetails.toggle()
}
}
}
}
}
}
}
if showDetails, let movie = selectedMovie {
// to get own view top pos
GeometryReader { geo in Color.clear.onAppear {
ownTop = geo.frame(in: .global).minY
print(ownTop)
}}
// overlay can become bigger than parent
.overlay (
Text("MovieDetail \(movie)")
.font(.largeTitle)
.matchedGeometryEffect(id: movie, in: namespace)
.frame(width: geo.size.width, height: geo.size.height)
.background(.gray)
.position(x: geo.frame(in: .global).midX, y: geo.frame(in: .global).midY - ownTop)
.onTapGesture {
withAnimation(Animation.interpolatingSpring(stiffness: 270, damping: 15)) {
showDetails.toggle()
}
}
)
}
}
}

In SwiftUI, is there a way to expand and contract a view to show hidden text and buttons in the expanded version of the view?

I'm making a simple task app and using ForEach to populate task rows with the task information from my model. I need a way to animate my task view to open up and reveal some description text and two buttons. I want to turn from A into B on tap, and then back again on tap:
Design Image
I've tried a couple things. I successfully got a proof-of-concept rectangle animating in a test project, but there are issues. The rectangle shrinks and grows from the centre point, vs. from the bottom only. When I place text inside it, the text doesn't get hidden and it looks really bad.
struct ContentView: View {
#State var animate = false
var animation: Animation = .spring()
var body: some View {
VStack {
Rectangle()
.frame(width: 200, height: animate ? 60 : 300)
.foregroundColor(.blue)
.onTapGesture {
withAnimation(animation) {
animate.toggle()
}
}
}
}
In my main app, I was able to replace my first task view (closed) with another view that's open. This works but it looks bad and it's not really doing what I want. It's effectively replacing the view with another one using a fade animation.
ForEach(taskArrayHigh) { task in
if animate == false {
TaskView(taskTitle: task.title, category: task.category?.rawValue ?? "", complete: task.complete?.rawValue ?? "", priorityColor: Color("HighPriority"), task: task, activeDate: activeDate)
.padding(.top, 10)
.padding(.horizontal)
.onTapGesture {
withAnimation(.easeIn) {
animate.toggle()
}
}
.transition(.move(edge: .bottom))
} else if animate == true {
TaskViewOpen(task: "Grocery Shopping", category: "Home", remaining: 204, completed: 4)
.padding(.top, 10)
.padding(.horizontal)
.onTapGesture {
withAnimation(.easeIn) {
animate.toggle()
}
}
}
Is there a way to animate my original closed view to open up and reveal the description text and buttons?
You are on the right track with your .transition line you have, but you want to make sure that the container stays the same and the contents change -- right now, you're replacing the entire view.
Here's a simple example illustrating the concept:
struct ContentView: View {
#State var isExpanded = false
var body: some View {
VStack {
Text("Headline")
if isExpanded {
Text("More Info")
Text("And more")
}
}
.padding()
.frame(maxWidth: .infinity)
.transition(.move(edge: .bottom))
.background(Color.gray.cornerRadius(10.0))
.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}
}
}
Since you're using it inside a ForEach, you'll probably want to abstract this into its own component, as it'll need its own #State to keep track of the expanded state as I've shown here.
Update, based on comments:
Example of using a PreferenceKey to get the height of the expandable view so that the frame can be animated and nothing fades in and out:
struct ContentView: View {
#State var isExpanded = false
#State var subviewHeight : CGFloat = 0
var body: some View {
VStack {
Text("Headline")
VStack {
Text("More Info")
Text("And more")
Text("And more")
Text("And more")
Text("And more")
Text("And more")
}
}
.background(GeometryReader {
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height)
})
.onPreferenceChange(ViewHeightKey.self) { subviewHeight = $0 }
.frame(height: isExpanded ? subviewHeight : 50, alignment: .top)
.padding()
.clipped()
.frame(maxWidth: .infinity)
.transition(.move(edge: .bottom))
.background(Color.gray.cornerRadius(10.0))
.onTapGesture {
withAnimation(.easeIn(duration: 2.0)) {
isExpanded.toggle()
}
}
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
Using Swift 5 you can use withAnimation and have the view hidden based on state.
ExpandViewer
Has a button to show and hide the inner view
Takes in a content view
struct ExpandViewer <Content: View>: View {
#State private var isExpanded = false
#ViewBuilder let expandableView : Content
var body: some View {
VStack {
Button(action: {
withAnimation(.easeIn(duration: 0.5)) {
self.isExpanded.toggle()
}
}){
Text(self.isExpanded ? "Hide" : "View")
.foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: 40, alignment: .center)
.background(.blue)
.cornerRadius(5.0)
}
if self.isExpanded {
self.expandableView
}
}
}
}
Using the viewer
ExpandViewer {
Text("Hidden Text")
Text("Hidden Text")
}

SwiftUI unable to click buttons in overlay/popover screen

I have a view that I present at the top of other views like a popover view, inside the view I have a couple of buttons. When I add a single button in the overplayed view and tap the button it works. However if I added multiple buttons and try to tap the buttons they don't work. Instead it clicks to the components bellow the view.
I would like to add multiple buttons and click them on the overlay view, I'm not sure what my mistake is on this code:
Here is my code:
struct MenuContent: View {
var body: some View {
List() {
ForEach(0..<2) { _ in
HStack {
ForEach(0..<4) { _ in
Button(action: {
print("tapped button")
}) {
VStack {
Text("Rev")
Image("trash.fill")
.resizable()
.scaledToFit()
.frame(width: 60, height: 60)
}
}.background(Color.blue)
}
}
}
}
}
}
OverlayView
struct OverlayMenu: View {
let width: CGFloat
#Binding var show: Bool
var body: some View {
return ZStack {
HStack {
MenuContent()
.frame(width: self.width, height: 160)
.cornerRadius(10, antialiased: false)
.offset(x: self.show ? 0 : -self.width, y: 285)
.animation(.spring())
Spacer()
}
.shadow(radius: 20)
}
}
}
ContentView
struct ContentView: View {
#State var show = true
var body: some View {
OverlayMenu(width: 350,
show: $show)
}
}
I think there is some trouble with List rows and tap gestures on them. You can deal with it if you want or you may try VStack instead of List and use Divider to divide "rows" and taps on the buttons will be handle as you expect. I changed your example a little to show how it works, I think you can handle design by yourself then:
struct MenuContent: View {
#State var hits = 0
var body: some View {
VStack {
Text("\(hits)")
Divider().frame(width: 250)
ForEach(0..<2) { _ in
ButtonsLine(hits: self.$hits)
Divider().frame(width: 250)
}
}
}
}
struct ButtonsLine: View {
#Binding var hits: Int
var body: some View {
HStack {
ForEach(0..<4) { value in
Button(action: {
print("tapped button")
self.hits += value + 1
}) {
ButtonDesign()
}
}
}
}
}
struct ButtonDesign: View {
var body: some View {
VStack {
Text("Rev")
.foregroundColor(.black)
Image(systemName: "trash")
.resizable()
.scaledToFit()
.foregroundColor(.red)
.frame(width: 60, height: 60)
}
.shadow(radius: 20)
}
}
and the result is:

Resources