Animation on Toggle Change with SwiftUI - ios

I am building a simple settings screen.
When then first setting is activated, the Speed Control appears. When it's turned off, the Speed Control disappears.
Based on what I know about SwiftUI, this should automatically animate based on the code below, but instead it just appears and disappears.
How can I make this animation nicer with SwiftUI, so it slides down from the cell above it, like in this presentation at 53:00?
import SwiftUI
import Combine
struct ContentView : View {
#ObjectBinding var settingsStore: SettingsStore
var body: some View {
NavigationView {
Form {
Toggle(isOn: $settingsStore.settingActivated.animation(.basic(duration: 3, curve: .easeInOut))) {
Text("Setting Activated")
}
if settingsStore.settingActivated {
VStack(alignment: .leading) {
Text("Speed Control")
HStack {
Image(systemName: "tortoise")
Slider(value: .constant(10), from: 0, through: 50, by: 1)
Image(systemName: "hare")
}
}.transition(.opacity)
}
}.navigationBarTitle(Text("Settings"))
}
}
}
class SettingsStore: BindableObject {
let didChange = PassthroughSubject<Void, Never>()
var settingActivated: Bool = UserDefaults.settingActivated {
didSet {
UserDefaults.settingActivated = settingActivated
didChange.send()
}
}
}
extension UserDefaults {
private struct Keys {
static let settingActivated = "SettingActivated"
}
static var settingActivated: Bool {
get {
return UserDefaults.standard.bool(forKey: Keys.settingActivated)
}
set {
UserDefaults.standard.set(newValue, forKey: Keys.settingActivated)
}
}
}

Related

SwiftUI menu button is slow to resize when title string changes length

I have the following SwiftUI menu:
import SwiftUI
struct ContentView: View {
private enum Constants {
static let arrowIcon = Image(systemName: "chevron.down")
static let background = Color.blue
static let buttonFont = Font.system(size: 14.0)
}
#State private var menuIndex = 0
private let menuItems = ["This year", "Last Year"]
private var menuTitle: String {
guard menuItems.indices.contains(menuIndex) else { return "" }
return menuItems[menuIndex]
}
// MARK: - Views
var body: some View {
makeMenu()
}
// MARK: - Buttons
private func menuItemTapped(title: String) {
guard let index = menuItems.firstIndex(of: title) else { return }
menuIndex = index
}
// MARK: - Factory
#ViewBuilder private func makeMenu() -> some View {
Menu {
ForEach(menuItems, id: \.self) { title in
Button(title, action: { menuItemTapped(title: title) })
}
} label: {
Text("\(menuTitle) \(Constants.arrowIcon)")
.font(Constants.buttonFont)
.fontWeight(.bold)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
When a menu item is tapped the index to the title array is updated causing the Text title to update. This works as desired however the text is slow to resize to the changes Last Year... will briefly show before it resizes correctly.
What am I doing wrong here?
This is because the size of the label is being set but not being changed. If you're okay with it, setting a maxWidth of .infinity on the frame of the Label gives you this capability.
#ViewBuilder private func makeMenu() -> some View {
VStack {
Menu {
ForEach(menuItems, id: \.self) { title in
Button(title, action: { menuItemTapped(title: title) })
}
} label: {
Text("\(menuTitle) \(Constants.arrowIcon)")
.font(Constants.buttonFont)
.fontWeight(.bold)
.frame(maxWidth: .infinity) //--> Add this line
}
}
}

Crash when attempting to scroll using ScrollViewReader in a SwiftUI List

I am trying to scroll to a newly appended view in a SwiftUI List using ScrollViewReader but keep crashing with EXC_BAD_INSTRUCTION in scrollTo(_:) after adding a few items. I am using Xcode 14.0.1 and iOS 16.0 simulator.
Here is a minimal demo that exhibits the issue:
struct ContentView: View {
#State var items = [Item]()
#State var scrollItem: UUID? = nil
var body: some View {
NavigationView {
ScrollViewReader { proxy in
List {
ForEach(items) { item in
Text(item.id.uuidString)
.id(item.id)
}
}
.listStyle(.inset)
.onChange(of: scrollItem) { newValue in
proxy.scrollTo(newValue)
}
}
.navigationTitle("List Demo")
.toolbar {
Button("Add") {
addItem()
}
}
}
}
func addItem() {
items.append(Item())
scrollItem = items.last?.id
}
}
struct Item: Identifiable {
let id = UUID()
}
I can get past the issue using a ScrollView instead of a List, but I would like to use the native swipe-to-delete functionality in the real project.
List is not supported well in ScrollViewReader. See this thread.
This solution is ugly, but works. The bad thing is that list blinks when you add a new item. I used one of the ideas from the thread above.
import SwiftUI
struct ContentView: View {
#State var items = [Item]()
#State var scrollItem: UUID? = nil
#State var isHidingList = false
var body: some View {
NavigationView {
VStack(alignment: .leading) {
if isHidingList {
list.hidden()
} else {
list
}
}
.onChange(of: scrollItem) { _ in
DispatchQueue.main.async {
self.isHidingList = false
}
}
.navigationTitle("List Demo")
.toolbar {
Button("Add") {
addItem()
}
}
}
}
var list: some View {
ScrollViewReader { proxy in
List {
ForEach(items) { item in
Text(item.id.uuidString)
.id(item.id)
}
}
.listStyle(.inset)
.onChange(of: scrollItem) { newValue in
guard !isHidingList else { return }
proxy.scrollTo(newValue)
}
.onAppear() {
guard !isHidingList else { return }
proxy.scrollTo(scrollItem)
}
}
}
func addItem() {
isHidingList = true
items.append(Item())
scrollItem = items.last?.id
}
}
struct Item: Identifiable {
let id = UUID()
}
Here's a version using the introspect library to find the underlying UIScrollView and scrolling it directly.
Two different .introspect() modifiers are used because in iOS 16 List is implemented with UICollectionView whereas in earlier versions UITableView is used.
There's no flickering / forced rendering using this method as it interacts with the .setContentOffset() directly.
struct ScrollListOnChangeIos16: View {
#State private var items: [String]
init() {
_items = State(initialValue: Array(0...25).map { "Placeholder \($0)" } )
}
// The .introspectX view modifiers will populate scroller
// they are actually UITableView or UICollectionView which both decend from UIScrollView
// https://github.com/siteline/SwiftUI-Introspect/releases/tag/0.1.4
#State private var scroller: UIScrollView?
func scrollToTop() {
scroller?.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
}
func scrollToBottom() {
// Making this async seems to make scroll more consistent to happen after
// items has been updated. *shrug?*
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
guard let scroller = self.scroller else { return }
let yOffset = scroller.contentSize.height - scroller.frame.height
if yOffset < 0 { return }
scroller.setContentOffset(CGPoint(x: 0, y: yOffset), animated: true)
}
}
var body: some View {
VStack {
HStack {
Button("Top") {
scrollToTop()
}
Spacer()
Button("Add Item") {
items.append(Date.timeIntervalSinceReferenceDate.description)
scrollToBottom()
}.buttonStyle(.borderedProminent)
Spacer()
Button("Bottom") {
scrollToBottom()
}
}.padding()
// The source of all my pain ...
List{
ForEach(items, id: \.self) {
Text($0)
}
.onDelete { offsets in
items.remove(atOffsets: offsets)
}
}
.listStyle(.plain)
.padding(.bottom, 50)
}
/* in iOS 16 List is backed by UICollectionView, no out of the box .introspectMethod ... nbd. */
.introspect(selector: TargetViewSelector.ancestorOrSiblingContaining, customize: { (collectionView: UICollectionView) in
guard #available(iOS 16, *) else { return }
self.scroller = collectionView
})
/* in iOS 15 List is backed by UITableView ... */
.introspectTableView(customize: { tableView in
guard #available(iOS 15, *) else { return }
self.scroller = tableView
})
}
}

SwiftUI Transition not happening

I am new to SwiftUI and I am trying to use the .transition, but for some reason no transition happens.
You can see the code below:
View
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
if self.viewModel.model.show {
Text("Showing")
.padding()
} else {
Text("Not Showing")
.padding()
.transition(.asymmetric(insertion: .scale, removal: .opacity))
}
Button {
self.viewModel.show()
} label: {
Text("Tap to change")
}
}
}
ViewModel
class ViewModel: ObservableObject {
#Published private(set) var model = Model()
func show() {
self.model.toggleShow()
}
}
Model
struct Model {
var show: Bool = true
mutating func toggleShow() {
self.show.toggle()
}
}
When I tap the button the text changes but no transition occurs.
I feel like I am missing something trivial here.
Can anyone please assist?
You need an animation (to animate transition) and a container (which performs actual transition, because default implicit Group does not do that).
Here is fixed part of code (tested with Xcode 13.2 / iOS 15.2)
*Note:Preview > Debug > Slow Animation for better visibility
var body: some View {
VStack { // << this !!
if self.viewModel.model.show {
Text("Showing")
.padding()
} else {
Text("Not Showing")
.padding()
.transition(.asymmetric(insertion: .scale, removal: .opacity))
}
}
.animation(.default, value: self.viewModel.model.show) // << here !!
Button {
self.viewModel.show()
} label: {
Text("Tap to change")
}
}
Your code is fine (besides the fact that you need a VStack wrapping the text and the button), you only need to tell SwiftUI to use the transition by wrapping the command inside withAnimation().
Here's what you simply need to do in ContentView (look at the Button):
#ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
if self.viewModel.model.show {
Text("Showing")
.padding()
} else {
Text("Not Showing")
.padding()
.transition(.asymmetric(insertion: .scale, removal: .opacity))
}
Button {
withAnimation { // This is what you need to trigger the transition
self.viewModel.show()
}
} label: {
Text("Tap to change")
}
}
.animation(.easeIn, value: self.viewModel.show)
}

Center SwiftUI view in top-level view

I am creating a loading indicator in SwiftUI that should always be centered in the top-level view of the view hierarchy (i.e centered in the whole screen in a fullscreen app). This would be easy in UIKit, but SwiftUI centres views relative to their parent view only and I am not able to get the positions of the parent views of the parent view.
Sadly my app is not fully SwiftUI based, so I cannot easily set properties on my root views that I could then access in my loading view - I need this view to be centered regardless of what the view hierarchy looks like (mixed UIKit - SwiftUI parent views). This is why answers like SwiftUI set position to centre of different view don't work for my use case, since in that example, you need to modify the view in which you want to centre your child view.
I have tried playing around with the .offset and .position functions of View, however, I couldn't get the correct inputs to always dynamically centre my loadingView regardless of screen size or regardless of what part of the whole screen rootView takes up.
Please find a minimal reproducible example of the problem below:
/// Loading view that should always be centered in the whole screen on the XY axis and should be the top view in the Z axis
struct CenteredLoadingView<RootView: View>: View {
private let rootView: RootView
init(rootView: RootView) {
self.rootView = rootView
}
var body: some View {
ZStack {
rootView
loadingView
}
// Ensure that `AnimatedLoadingView` is displayed above all other views, whose `zIndex` would be higher than `rootView`'s by default
.zIndex(.infinity)
}
private var loadingView: some View {
VStack {
Color.white
.frame(width: 48, height: 72)
Text("Loading")
.foregroundColor(.white)
}
.frame(width: 142, height: 142)
.background(Color.primary.opacity(0.7))
.cornerRadius(10)
}
}
View above which the loading view should be displayed:
struct CenterView: View {
var body: some View {
return VStack {
Color.gray
HStack {
CenteredLoadingView(rootView: list)
otherList
}
}
}
var list: some View {
List {
ForEach(1..<6) {
Text($0.description)
}
}
}
var otherList: some View {
List {
ForEach(6..<11) {
Text($0.description)
}
}
}
}
This is what the result looks like:
This is how the UI should look like:
I have tried modifying the body of CenteredLoadingView using a GeometryReader and .frame(in: .global) to get the global screen size, but what I've achieved is that now my loadingView is not visible at all.
var body: some View {
GeometryReader<AnyView> { geo in
let screen = geo.frame(in: .global)
let stack = ZStack {
self.rootView
self.loadingView
.position(x: screen.midX, y: screen.midY)
// Offset doesn't work either
//.offset(x: -screen.origin.x, y: -screen.origin.y)
}
// Ensure that `AnimatedLoadingView` is displayed above all other views, whose `zIndex` would be higher than `rootView`'s by default
.zIndex(.infinity)
return AnyView(stack)
}
}
Here is a demo of possible approach. The idea is to use injected UIView to access UIWindow and then show loading view as a top view of window's root viewcontroller view.
Tested with Xcode 12 / iOS 14 (but SwiftUI 1.0 compatible)
Note: animations, effects, etc. are possible but are out scope for simplicity
struct CenteredLoadingView<RootView: View>: View {
private let rootView: RootView
#Binding var isActive: Bool
init(rootView: RootView, isActive: Binding<Bool>) {
self.rootView = rootView
self._isActive = isActive
}
var body: some View {
rootView
.background(Activator(showLoading: $isActive))
}
struct Activator: UIViewRepresentable {
#Binding var showLoading: Bool
#State private var myWindow: UIWindow? = nil
func makeUIView(context: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async {
self.myWindow = view.window
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
guard let holder = myWindow?.rootViewController?.view else { return }
if showLoading && context.coordinator.controller == nil {
context.coordinator.controller = UIHostingController(rootView: loadingView)
let view = context.coordinator.controller!.view
view?.backgroundColor = UIColor.black.withAlphaComponent(0.8)
view?.translatesAutoresizingMaskIntoConstraints = false
holder.addSubview(view!)
holder.isUserInteractionEnabled = false
view?.leadingAnchor.constraint(equalTo: holder.leadingAnchor).isActive = true
view?.trailingAnchor.constraint(equalTo: holder.trailingAnchor).isActive = true
view?.topAnchor.constraint(equalTo: holder.topAnchor).isActive = true
view?.bottomAnchor.constraint(equalTo: holder.bottomAnchor).isActive = true
} else if !showLoading {
context.coordinator.controller?.view.removeFromSuperview()
context.coordinator.controller = nil
holder.isUserInteractionEnabled = true
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator {
var controller: UIViewController? = nil
}
private var loadingView: some View {
VStack {
Color.white
.frame(width: 48, height: 72)
Text("Loading")
.foregroundColor(.white)
}
.frame(width: 142, height: 142)
.background(Color.primary.opacity(0.7))
.cornerRadius(10)
}
}
}
struct CenterView: View {
#State private var isLoading = false
var body: some View {
return VStack {
Color.gray
HStack {
CenteredLoadingView(rootView: list, isActive: $isLoading)
otherList
}
Button("Demo", action: load)
}
.onAppear(perform: load)
}
func load() {
self.isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.isLoading = false
}
}
var list: some View {
List {
ForEach(1..<6) {
Text($0.description)
}
}
}
var otherList: some View {
List {
ForEach(6..<11) {
Text($0.description)
}
}
}
}

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)

Resources