Animate Addition/Removal of View in SwiftUI - ios

I have created a bottom alert that I want to animate when it is to be presented or removed. Currently it is not doing any animation, only showing up and removing itself when needed.
I've tried using .transition(.move(edge: .bottom)) on the actual view but no animation is shown.
How can I add a slide up/down animation for this view?
var body: some View {
ZStack {
VStack {
toolbar
Spacer()
switch viewModel.status {
case .loading:
LoadingView(isAnimating: $isLoading)
case .loaded(_):
productView
case .error(_):
Text("Please Retry.")
.onAppear {
buildBottomAlert(type: .error)
}
}
}
VStack {
Spacer()
if let bottomView = bottomAlertView {
bottomView
.transition(.move(edge: .bottom))
}
}
}
}
Bottom Alert Builder
func buildBottomAlert(type: BottomAlertType) {
self.bottomAlertView = BottomAlert(type: type)
self.showBottomAlert = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.removeBottomAlert()
}
}
func removeBottomAlert() {
bottomAlertView = nil
showBottomAlert = false
}

For animation, you need to add your show/hide code inside body of withAnimation function.
withAnimation {
buildBottomAlert(type: .error)
}
Also, update your buildBottomAlert function like this.
func buildBottomAlert(type: BottomAlertType) {
self.bottomAlertView = BottomAlert(type: type)
self.showBottomAlert = true
Task {
//Sleep for 2 seconds
try await Task.sleep(nanoseconds: 2_000_000_000)
withAnimation {
removeBottomAlert()
}
}
}
Note: I have used sleep(nanoseconds:) instead of asyncAfter(deadline:execute:).

Related

SwiftUI bi-directional move transition moving the wrong way in certain cases

I have four main functional areas of my app that can be accessed by the user via a custom tab bar at the bottom of the the ContentView. I want to use a slide transition to move between the views when the user taps the desired function in the tab bar.
I also want the direction of the slide to be based on the relative position of the options on the tab bar. That is, if going from tab 1 to tab 3, the views will slide from right to left, or if going from tab 3 to tab 2, the views will slide from left to right.
This works perfectly on the first change of view and for any subsequent change of view that changes direction of the slide. E.g., the following sequence of view changes work: 1->3, 3->2, 2->4, 4->1.
However, any time there is a change of view where the direction is the same as the previous direction, it doesn't work correctly. E.g., the bolded changes in the following sequence don't work properly. 1->2, 2->3, 3->4, 4->3, 3->2.
In the above-mentioned transitions that don't work properly, the incoming view enters from the appropriate direction, but the outgoing view departs in the wrong direction. For example, the image at the bottom of this post shows the new view moving in appropriately from right to left, but the departing view is moving from left to right, leaving the white space on the left (it should also be moving from right to left along with the incoming view).
Any thoughts on why this might be happening / how to correct it?
I'm using iOS 16 for my app.
Following is a complete code sample demonstrating this issue:
import SwiftUI
#main
struct TabBar_testingApp: App {
#StateObject var tabOption = TabOption()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(tabOption)
}
}
}
class TabOption: ObservableObject {
#Published var tab: TabItem = .tab1
#Published var slideLeft: Bool = true
}
enum TabItem: Int, CaseIterable {
// MARK: These are the four main elements of the app that are navigated to via the custom tab or sidebar controls
case tab1 = 0
case tab2 = 1
case tab3 = 2
case tab4 = 3
var description: String {
switch self {
case .tab1: return "Tab 1"
case .tab2: return "Tab 2"
case .tab3: return "Tab 3"
case .tab4: return "Tab 4"
}
}
var icon: String {
switch self {
case .tab1: return "1.circle"
case .tab2: return "2.circle"
case .tab3: return "3.circle"
case .tab4: return "4.circle"
}
}
}
struct ContentView: View {
#EnvironmentObject var tabOption: TabOption
var body: some View {
NavigationStack {
VStack {
// Content
Group {
switch tabOption.tab {
case TabItem.tab1:
SlideOneView()
case TabItem.tab2:
SlideTwoView()
case TabItem.tab3:
Slide3View()
case TabItem.tab4:
SlideFourView()
}
}
// Use a slide transition when changing the tab views
.transition(.move(edge: tabOption.slideLeft ? .leading : .trailing))
Spacer()
// Custom tab bar
HStack {
Spacer()
// Open tab 1
Button(action: {
withAnimation {
// Set the direction the tabs will slide when transitioning between the tabs
tabOption.slideLeft = true
// Change to the selected tab
tabOption.tab = TabItem.tab1
}
}) {
VStack {
Image(systemName: TabItem.tab1.icon).font(.title2)
Text(TabItem.tab1.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab1 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 2
Button(action: {
withAnimation {
// Set the direction the tabs will slide when transitioning between the tabs
if tabOption.tab.rawValue == TabItem.tab1.rawValue {
tabOption.slideLeft = false
} else {
tabOption.slideLeft = true
}
// Change to the selected tab
tabOption.tab = TabItem.tab2
}
}) {
VStack {
Image(systemName: TabItem.tab2.icon).font(.title2)
Text(TabItem.tab2.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab2 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 3
Button(action: {
withAnimation {
// Set the direction the tabs will slide when transitioning between the tabs
if tabOption.tab.rawValue == TabItem.tab4.rawValue {
tabOption.slideLeft = true
} else {
tabOption.slideLeft = false
}
// Change to the selected tab
tabOption.tab = TabItem.tab3
}
}) {
VStack {
Image(systemName: TabItem.tab3.icon).font(.title2)
Text(TabItem.tab3.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab3 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 4
Button(action: {
withAnimation {
// Set the direction the tabs will slide when transitioning between the tabs
tabOption.slideLeft = false
// Change to the selected tab
tabOption.tab = TabItem.tab4
}
}) {
VStack {
Image(systemName: TabItem.tab4.icon).font(.title2)
Text(TabItem.tab4.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab4 ? .primary : .secondary)
.font(.title)
}
Spacer()
} // HStack closure
.foregroundStyle(.blue)
.padding(.top, 5)
}
}
}
}
struct SlideOneView: View {
var body: some View {
ZStack {
Group {
Color.blue
Text("Tab Content 1")
.font(.largeTitle)
.foregroundColor(.white)
}
}
}
}
struct SlideTwoView: View {
var body: some View {
ZStack {
Group {
Color.green
Text("Tab Content 2")
.font(.largeTitle)
.foregroundColor(.white)
}
}
}
}
struct Slide3View: View {
var body: some View {
ZStack {
Group {
Color.purple
Text("Tab Content 3")
.font(.largeTitle)
.foregroundColor(.white)
}
}
}
}
struct SlideFourView: View {
var body: some View {
ZStack {
Group {
Color.red
Text("Tab Content 4")
.font(.largeTitle)
.foregroundColor(.white)
}
}
}
}
And finally, here's the screenshot where the bottom (departing) view is moving incorrectly from left to right which briefly leaves white space on the left, while the incoming view is correctly moving from right to left.
HERE'S MY REVISED CODE PER COMMENTS BELOW:
class TabOption: ObservableObject {
#Published var tab: TabItem = .tab1
#Published var slideLeft: Bool = true
func changeTab(to newTab: TabItem) {
switch newTab.rawValue {
// case let allows you to make a comparison in the case statement
// This determines the direction is decreasing, so we want a right slide
case let t where t < tab.rawValue:
slideLeft = false
// This determines the direction is increasing, so we want a left slide
case let t where t > tab.rawValue:
slideLeft = true
// This determines that the user tapped this tab, so do nothing
default:
return
}
// We have determined the proper direction, so change tabs.
withAnimation(.easeInOut) {
tab = newTab
}
}
}
enum TabItem: Int, CaseIterable {
// MARK: These are the four main elements of the app that are navigated to via the custom tab or sidebar controls
case tab1 = 0
case tab2 = 1
case tab3 = 2
case tab4 = 3
var description: String {
switch self {
case .tab1: return "Tab 1"
case .tab2: return "Tab 2"
case .tab3: return "Tab 3"
case .tab4: return "Tab 4"
}
}
var icon: String {
switch self {
case .tab1: return "1.circle"
case .tab2: return "2.circle"
case .tab3: return "3.circle"
case .tab4: return "4.circle"
}
}
}
struct ContentView: View {
#EnvironmentObject var tabOption: TabOption
var body: some View {
NavigationStack {
VStack {
// Content
Group {
switch tabOption.tab {
case TabItem.tab1:
SlideOneView()
case TabItem.tab2:
SlideTwoView()
case TabItem.tab3:
Slide3View()
case TabItem.tab4:
SlideFourView()
}
}
// Use a slide transition when changing the tab views
.transition(
.asymmetric(
insertion: .move(edge: tabOption.slideLeft ? .trailing : .leading),
removal: .move(edge: tabOption.slideLeft ? .leading : .trailing)
)
)
Spacer()
// Custom tab bar
HStack {
Spacer()
// Open tab 1
Button(action: {
withAnimation {
tabOption.changeTab(to: .tab1)
}
}) {
VStack {
Image(systemName: TabItem.tab1.icon).font(.title2)
Text(TabItem.tab1.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab1 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 2
Button(action: {
withAnimation {
tabOption.changeTab(to: .tab2)
}
}) {
VStack {
Image(systemName: TabItem.tab2.icon).font(.title2)
Text(TabItem.tab2.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab2 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 3
Button(action: {
withAnimation {
tabOption.changeTab(to: .tab3)
}
}) {
VStack {
Image(systemName: TabItem.tab3.icon).font(.title2)
Text(TabItem.tab3.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab3 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 4
Button(action: {
tabOption.changeTab(to: .tab4)
}) {
VStack {
Image(systemName: TabItem.tab4.icon).font(.title2)
Text(TabItem.tab4.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab4 ? .primary : .secondary)
.font(.title)
}
Spacer()
} // HStack closure
.foregroundStyle(.blue)
.padding(.top, 5)
}
}
}
}
Here's a GIF of the issue using the revised code (apologies for the gif compression "squashing" the screen image, but you get the idea):
So, a couple of things. First, you had way too much logic in your view code. Remember the DRY principal(Don't Repeat Yourself). Essentially, you are using TabOption, so your logic should go in there. I added a function to TabOption that contains all the logic to change tabs:
class TabOption: ObservableObject {
#Published var tab: TabItem = .tab1
#Published var slideLeft: Bool = true
func changeTab(to newTab: TabItem) {
switch newTab.rawValue {
// case let allows you to make a comparison in the case statement
// This determines the direction is decreasing, so we want a right slide
case let t where t < tab.rawValue:
slideLeft = false
// This determines the direction is increasing, so we want a left slide
case let t where t > tab.rawValue:
slideLeft = true
// This determines that the user tapped this tab, so do nothing
default:
return
}
// We have determined the proper direction, so change tabs.
withAnimation(.easeInOut) {
tab = newTab
}
}
}
With that in place, things are easier to reason. In the end, the views were not sliding in the directions you expected because you didn't realize you were dealing with two views that you wanted to do different things with. If you have a slide left, you want the original view to exit by moving its trailing edge, and the new view moving its leading edge. The right slide is reversed. Your transition was telling them to enter and exit from the same direction. What you want is an .asymmetric() transition like this:
.transition(
.asymmetric(
insertion: .move(edge: tabOption.slideLeft ? .trailing : .leading),
removal: .move(edge: tabOption.slideLeft ? .leading : .trailing)
)
)
Lastly, to complete this, each of your button actions are simply like this:
// Open tab 1
Button(action: {
tabOption.changeTab(to: .tab1)
}) {
...
}
Edit:
Using the code provided, this is the result following your comments:
As you can see, there are no issues. Please make sure you adopted all of my code, and not just the asymmetric transition. I am not sure that having the tabOption.slideLeft = true inside the animation block is not also causing problems.

SwiftUI - Navigation View title is overlapping list

I have a simple list with a navigation view, where the navigation view is overlapping the list while scrolling.
Here is the look I want.
Here is what I am getting with the overlap
Here is the code
struct MedicalDashboard: View {
let menuItemData = MenuItemList()
var body: some View {
NavigationView {
List(menuItemData.items, id: \.id) { item in
MenuItemRow(menuItem: item)
}
.listStyle(.insetGrouped)
.navigationTitle("Dashboard")
.navigationBarItems(trailing:
Button(action: {
// TODO: - Pop up a sheet for the settings page.
print("User icon pressed...")
}) {
Image(systemName: "person.crop.circle").imageScale(.large)
}
)
.padding(.top)
}
}
}
when I add padding(.top) the overlap stops but I get a different color background on the navigation
On Xcode 13.4, except a missing }, without the .padding(.top) and with a custom List everything works like a charm for me.
The problem might come from MenuItemList().
I have still updated your code by replacing .navigationBarItems and by adding the sheet for you:
struct MedicalDashboard: View {
#State private var showSheet = false
var body: some View {
NavigationView {
List { // Custom list
Text("Hello")
Text("Salut")
}
.listStyle(.insetGrouped)
.navigationTitle("Dashboard")
.toolbar() { // .navigationBarItems will be deprecated
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showSheet.toggle()
print("User icon pressed.")
}, label: {
Image(systemName: "person.crop.circle")
})
.sheet(isPresented: $showSheet) { /* SettingsView() */ }
}
}
}
} // New
}
Edit your post and show us MenuItemList().
Try this:
Swift
struct MedicalDashboard: View {
init() {
if #available(iOS 15, *) {
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().scrollEdgeAppearance = appearance
}
}
...
}

SwiftUI how to show large navigation title without using a navigation view?

I'm using the below PullToRefreshHack from this SO Answer which works well, but the problem is when I wrap it inside a NavigationView (which I need to be able to show this view's large navigation title) I loose the functionality of the pull to refresh. I'm not sure why though? 🤔 How can I fix this, or remove the NavigationView but still show a large title at the top?
//Usage
var body: some View {
NavigationView {
ScrollView {
PullToRefreshHack(coordinateSpaceName: "pullToRefreshInTrendsView") {
print("user pulled to refresh")
generator.impactOccurred()
self.loadDataForTrendsView()
}
struct PullToRefreshHack: View {
var coordinateSpaceName: String
var onRefresh: ()->Void
#State var needRefresh: Bool = false
var body: some View {
GeometryReader { geo in
if (geo.frame(in: .named(coordinateSpaceName)).midY > 50) {
Spacer()
.onAppear {
needRefresh = true
}
} else if (geo.frame(in: .named(coordinateSpaceName)).maxY < 10) {
Spacer()
.onAppear {
if needRefresh {
needRefresh = false
onRefresh()
}
}
}
HStack {
Spacer()
if needRefresh {
ProgressView()
} else {
Text("")
}
Spacer()
}
.onAppear {
//print("PullToRefreshHack VIEW .onAppear is called")
}
}.padding(.top, -50)
}
}

How to scroll List programmatically in SwiftUI?

It looks like in current tools/system, just released Xcode 11.4 / iOS 13.4, there will be no SwiftUI-native support for "scroll-to" feature in List. So even if they, Apple, will provide it in next major released, I will need backward support for iOS 13.x.
So how would I do it in most simple & light way?
scroll List to end
scroll List to top
and others
(I don't like wrapping full UITableView infrastructure into UIViewRepresentable/UIViewControllerRepresentable as was proposed earlier on SO).
SWIFTUI 2.0
Here is possible alternate solution in Xcode 12 / iOS 14 (SwiftUI 2.0) that can be used in same scenario when controls for scrolling is outside of scrolling area (because SwiftUI2 ScrollViewReader can be used only inside ScrollView)
Note: Row content design is out of consideration scope
Tested with Xcode 12b / iOS 14
class ScrollToModel: ObservableObject {
enum Action {
case end
case top
}
#Published var direction: Action? = nil
}
struct ContentView: View {
#StateObject var vm = ScrollToModel()
let items = (0..<200).map { $0 }
var body: some View {
VStack {
HStack {
Button(action: { vm.direction = .top }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { vm.direction = .end }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
ScrollViewReader { sp in
ScrollView {
LazyVStack {
ForEach(items, id: \.self) { item in
VStack(alignment: .leading) {
Text("Item \(item)").id(item)
Divider()
}.frame(maxWidth: .infinity).padding(.horizontal)
}
}.onReceive(vm.$direction) { action in
guard !items.isEmpty else { return }
withAnimation {
switch action {
case .top:
sp.scrollTo(items.first!, anchor: .top)
case .end:
sp.scrollTo(items.last!, anchor: .bottom)
default:
return
}
}
}
}
}
}
}
}
SWIFTUI 1.0+
Here is simplified variant of approach that works, looks appropriate, and takes a couple of screens code.
Tested with Xcode 11.2+ / iOS 13.2+ (also with Xcode 12b / iOS 14)
Demo of usage:
struct ContentView: View {
private let scrollingProxy = ListScrollingProxy() // proxy helper
var body: some View {
VStack {
HStack {
Button(action: { self.scrollingProxy.scrollTo(.top) }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { self.scrollingProxy.scrollTo(.end) }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
List {
ForEach(0 ..< 200) { i in
Text("Item \(i)")
.background(
ListScrollingHelper(proxy: self.scrollingProxy) // injection
)
}
}
}
}
}
Solution:
Light view representable being injected into List gives access to UIKit's view hierarchy. As List reuses rows there are no more values then fit rows into screen.
struct ListScrollingHelper: UIViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeUIView(context: Context) -> UIView {
return UIView() // managed by SwiftUI, no overloads
}
func updateUIView(_ uiView: UIView, context: Context) {
proxy.catchScrollView(for: uiView) // here UIView is in view hierarchy
}
}
Simple proxy that finds enclosing UIScrollView (needed to do once) and then redirects needed "scroll-to" actions to that stored scrollview
class ListScrollingProxy {
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: UIScrollView?
func catchScrollView(for view: UIView) {
if nil == scrollView {
scrollView = view.enclosingScrollView()
}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentSize.height +
scroller.contentInset.bottom + scroller.contentInset.top - 1
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
scroller.scrollRectToVisible(rect, animated: true)
}
}
}
extension UIView {
func enclosingScrollView() -> UIScrollView? {
var next: UIView? = self
repeat {
next = next?.superview
if let scrollview = next as? UIScrollView {
return scrollview
}
} while next != nil
return nil
}
}
Just scroll to the id:
scrollView.scrollTo(ROW-ID)
Since SwiftUI structured designed Data-Driven, You should know all of your items IDs. So you can scroll to any id with ScrollViewReader from iOS 14 and with Xcode 12
struct ContentView: View {
let items = (1...100)
var body: some View {
ScrollViewReader { scrollProxy in
ScrollView {
ForEach(items, id: \.self) { Text("\($0)"); Divider() }
}
HStack {
Button("First!") { withAnimation { scrollProxy.scrollTo(items.first!) } }
Button("Any!") { withAnimation { scrollProxy.scrollTo(50) } }
Button("Last!") { withAnimation { scrollProxy.scrollTo(items.last!) } }
}
}
}
}
Note that ScrollViewReader should support all scrollable content, but now it only supports ScrollView
Preview
Preferred way
This answer is getting more attention, but I should state that the ScrollViewReader is the right way to do this. The introspect way is only if the reader/proxy doesn't work for you, because of a version restrictions.
ScrollViewReader { proxy in
ScrollView(.vertical) {
TopView().id("TopConstant")
...
MiddleView().id("MiddleConstant")
...
Button("Go to top") {
proxy.scrollTo("TopConstant", anchor: .top)
}
.id("BottomConstant")
}
.onAppear{
proxy.scrollTo("MiddleConstant")
}
.onChange(of: viewModel.someProperty) { _ in
proxy.scrollTo("BottomConstant")
}
}
The strings should be defined in one place, outside of the body property.
Legacy answer
Here is a simple solution that works on iOS13&14:
Using Introspect.
My case was for initial scroll position.
ScrollView(.vertical, showsIndicators: false, content: {
...
})
.introspectScrollView(customize: { scrollView in
scrollView.scrollRectToVisible(CGRect(x: 0, y: offset, width: 100, height: 300), animated: false)
})
If needed the height may be calculated from the screen size or the element itself.
This solution is for Vertical scroll. For horizontal you should specify x and leave y as 0
Thanks Asperi, great tip. I needed to have a List scroll up when new entries where added outside the view. Reworked to suit macOS.
I took the state/proxy variable to an environmental object and used this outside the view to force the scroll. I found I had to update it twice, the 2nd time with a .5sec delay to get the best result. The first update prevents the view from scrolling back to the top as the row is added. The 2nd update scrolls to the last row. I'm a novice and this is my first stackoverflow post :o
Updated for MacOS:
struct ListScrollingHelper: NSViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeNSView(context: Context) -> NSView {
return NSView() // managed by SwiftUI, no overloads
}
func updateNSView(_ nsView: NSView, context: Context) {
proxy.catchScrollView(for: nsView) // here NSView is in view hierarchy
}
}
class ListScrollingProxy {
//updated for mac osx
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: NSScrollView?
func catchScrollView(for view: NSView) {
//if nil == scrollView { //unB - seems to lose original view when list is emptied
scrollView = view.enclosingScrollView()
//}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentView.frame.minY
if let documentHeight = scroller.documentView?.frame.height {
rect.origin.y = documentHeight - scroller.contentSize.height
}
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
//tried animations without success :(
scroller.contentView.scroll(to: NSPoint(x: rect.minX, y: rect.minY))
scroller.reflectScrolledClipView(scroller.contentView)
}
}
}
extension NSView {
func enclosingScrollView() -> NSScrollView? {
var next: NSView? = self
repeat {
next = next?.superview
if let scrollview = next as? NSScrollView {
return scrollview
}
} while next != nil
return nil
}
}
my two cents for deleting and repositioning list at any point based on other logic.. i.e. after delete/update, for example going to top.
(this is a ultra-reduced sample, I used this code after network call back to reposition: after network call I change previousIndex )
struct ContentView: View {
#State private var previousIndex : Int? = nil
#State private var items = Array(0...100)
func removeRows(at offsets: IndexSet) {
items.remove(atOffsets: offsets)
self.previousIndex = offsets.first
}
var body: some View {
ScrollViewReader { (proxy: ScrollViewProxy) in
List{
ForEach(items, id: \.self) { Text("\($0)")
}.onDelete(perform: removeRows)
}.onChange(of: previousIndex) { (e: Equatable) in
proxy.scrollTo(previousIndex!-4, anchor: .top)
//proxy.scrollTo(0, anchor: .top) // will display 1st cell
}
}
}
}
This can now be simplified with all new ScrollViewProxy in Xcode 12, like so:
struct ContentView: View {
let itemCount: Int = 100
var body: some View {
ScrollViewReader { value in
VStack {
Button("Scroll to top") {
value.scrollTo(0)
}
Button("Scroll to buttom") {
value.scrollTo(itemCount-1)
}
ScrollView {
LazyVStack {
ForEach(0 ..< itemCount) { i in
Text("Item \(i)")
.frame(height: 50)
.id(i)
}
}
}
}
}
}
}
MacOS 11: In case you need to scroll a list based on input outside the view hierarchy. I have followed the original scroll proxy pattern using the new scrollViewReader:
struct ScrollingHelperInjection: NSViewRepresentable {
let proxy: ScrollViewProxy
let helper: ScrollingHelper
func makeNSView(context: Context) -> NSView {
return NSView()
}
func updateNSView(_ nsView: NSView, context: Context) {
helper.catchProxy(for: proxy)
}
}
final class ScrollingHelper {
//updated for mac os v11
private var proxy: ScrollViewProxy?
func catchProxy(for proxy: ScrollViewProxy) {
self.proxy = proxy
}
func scrollTo(_ point: Int) {
if let scroller = proxy {
withAnimation() {
scroller.scrollTo(point)
}
} else {
//problem
}
}
}
Environmental object:
#Published var scrollingHelper = ScrollingHelper()
In the view: ScrollViewReader { reader in .....
Injection in the view:
.background(ScrollingHelperInjection(proxy: reader, helper: scrollingHelper)
Usage outside the view hierarchy: scrollingHelper.scrollTo(3)
As mentioned in #lachezar-todorov's answer Introspect is a nice library to access UIKit elements in SwiftUI. But be aware that the block you use for accessing UIKit elements are being called multiple times. This can really mess up your app state. In my cas CPU usage was going %100 and app was getting unresponsive. I had to use some pre conditions to avoid it.
ScrollView() {
...
}.introspectScrollView { scrollView in
if aPreCondition {
//Your scrolling logic
}
}
Another cool way is to just use namespace wrappers:
A dynamic property type that allows access to a namespace defined by the persistent identity of the object containing the property (e.g. a view).
struct ContentView: View {
#Namespace private var topID
#Namespace private var bottomID
let items = (0..<100).map { $0 }
var body: some View {
ScrollView {
ScrollViewReader { proxy in
Section {
LazyVStack {
ForEach(items.indices, id: \.self) { index in
Text("Item \(items[index])")
.foregroundColor(.black)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.green.cornerRadius(16))
}
}
} header: {
HStack {
Text("header")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(bottomID)
}
}
) {
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(topID)
} footer: {
HStack {
Text("Footer")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(topID) }
}
) {
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(bottomID)
}
.padding()
}
}
.foregroundColor(.white)
.background(.black)
}
}
Two parts:
Wrap the List (or ScrollView) with ScrollViewReader
Use the scrollViewProxy (that comes from ScrollViewReader) to scroll to an id of an element in the List. You can seemingly use EmptyView().
The example below uses a notification for simplicity (use a function if you can instead!).
ScrollViewReader { scrollViewProxy in
List {
EmptyView().id("top")
}
.onReceive(NotificationCenter.default.publisher(for: .ScrollToTop)) { _ in
// when using an anchor of `.top`, it failed to go all the way to the top
// so here we add an extra -50 so it goes to the top
scrollViewProxy.scrollTo("top", anchor: UnitPoint(x: 0, y: -50))
}
}
extension Notification.Name {
static let ScrollToTop = Notification.Name("ScrollToTop")
}
NotificationCenter.default.post(name: .ScrollToTop, object: nil)

Multiple action handlers of Buttons gets fired, only when the parent VStack is inside a List in SwiftUI

Case 1:- When I have multiple Buttons in a VStack, on clicking of any one of them, the action handlers of both the buttons executes immediately, this happens only when the VStack is a child of List.
For eg-
List {
VStack {
Button(action: { print("A") }) {
Text("Button A")
}
Button(action: { print("B") }) {
Text("Button B")
}
}
}
Here when you click on any one Button(say Button B), the o/p is:-
A
B
Case 2:- Try that with just a VStack, it works fine.
For eg-
VStack {
Button(action: { print("C") }) {
Text("Button C")
}
Button(action: { print("D") }) {
Text("Button D")
}
}
Here when you click on any one Button(say Button D), the o/p is:-
D
I am new to iOS programming, please help me understand where I am going wrong or is it an issue with SwiftUI?
Set the button style to something different from the default, e.g., BorderlessButtonStyle()
struct Test: View {
var body: some View {
NavigationView {
List {
ForEach([
"Line 1",
"Line 2",
], id: \.self) {
item in
HStack {
Text("\(item)")
Spacer()
Button(action: { print("\(item) 1")}) {
Text("Button 1")
}
Button(action: { print("\(item) 2")}) {
Text("Button 2")
}
}
}
.onDelete { _ in }
.buttonStyle(BorderlessButtonStyle())
}
.navigationBarItems(trailing: EditButton())
}
.accentColor(.red)
}
}
Buttons inside List won't work as expected. Most likely a bug, but not acknowledge officially. Since it wasn't acknowledge, nobody can tell when/if it's going to be fixed.
In the meantime, you may use a custom button, like the one below. It replicates the Button behavior (color and dimming on tap):
struct ContentView: View {
#State private var flag = false
var body: some View {
List {
VStack {
MyButton(label: "Button A") {
print("A")
}
MyButton(label: "Button B") {
print("B")
}
}
}
}
}
struct MyButton: View {
#State private var tapped = false
let label: String
let action: () -> ()
var body: some View {
let g = DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged({ _ in
withAnimation { self.tapped = true }
})
.onEnded({ _ in
withAnimation { self.tapped = false }
self.action()
})
return Text(label)
.foregroundColor(Color(UIColor.link)
.opacity(tapped ? 0.5 : 1.0))
.gesture(g)
}
}

Resources