Center SwiftUI view in top-level view - ios

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

Related

Why is my Text Editor not updating to my selected background color? [duplicate]

TextEditor seems to have a default white background. So the following is not working and it displayed as white instead of defined red:
var body: some View {
TextEditor(text: .constant("Placeholder"))
.background(Color.red)
}
Is it possible to change the color to a custom one?
iOS 16
You should hide the default background to see your desired one:
TextEditor(text: .constant("Placeholder"))
.scrollContentBackground(.hidden) // <- Hide it
.background(.red) // To see this
iOS 15 and below
TextEditor is backed by UITextView. So you need to get rid of the UITextView's backgroundColor first and then you can set any View to the background.
struct ContentView: View {
init() {
UITextView.appearance().backgroundColor = .clear
}
var body: some View {
List {
TextEditor(text: .constant("Placeholder"))
.background(.red)
}
}
}
Demo
You can find my simple trick for growing TextEditor here in this answer
Pure SwiftUI solution on iOS and macOS
colorMultiply is your friend.
struct ContentView: View {
#State private var editingText: String = ""
var body: some View {
TextEditor(text: $editingText)
.frame(width: 400, height: 100, alignment: .center)
.cornerRadius(3.0)
.colorMultiply(.gray)
}
}
Update iOS 16 / SwiftUI 4.0
You need to use .scrollContentBackground(.hidden) instead of UITextView.appearance().backgroundColor = .clear
https://twitter.com/StuFFmc/status/1556561422431174656
Warning: This is an iOS 16 only so you'll probably need some if #available and potentially two different TextEditor component.
extension View {
/// Layers the given views behind this ``TextEditor``.
func textEditorBackground<V>(#ViewBuilder _ content: () -> V) -> some View where V : View {
self
.onAppear {
UITextView.appearance().backgroundColor = .clear
}
.background(content())
}
}
Custom Background color with SwiftUI on macOS
On macOS, unfortunately, you have to fallback to AppKit and wrap NSTextView.
You need to declare a view that conforms to NSViewRepresentable
This should give you pretty much the same behaviour as SwiftUI's TextEditor-View and since the wrapped NSTextView does not draw its background, you can use the .background-ViewModifier to change the background
struct CustomizableTextEditor: View {
#Binding var text: String
var body: some View {
GeometryReader { geometry in
NSScrollableTextViewRepresentable(text: $text, size: geometry.size)
}
}
}
struct NSScrollableTextViewRepresentable: NSViewRepresentable {
typealias Representable = Self
// Hook this binding up with the parent View
#Binding var text: String
var size: CGSize
// Get the UndoManager
#Environment(\.undoManager) var undoManger
// create an NSTextView
func makeNSView(context: Context) -> NSScrollView {
// create NSTextView inside NSScrollView
let scrollView = NSTextView.scrollableTextView()
let nsTextView = scrollView.documentView as! NSTextView
// use SwiftUI Coordinator as the delegate
nsTextView.delegate = context.coordinator
// set drawsBackground to false (=> clear Background)
// use .background-modifier later with SwiftUI-View
nsTextView.drawsBackground = false
// allow undo/redo
nsTextView.allowsUndo = true
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
// get wrapped nsTextView
guard let nsTextView = scrollView.documentView as? NSTextView else {
return
}
// fill entire given size
nsTextView.minSize = size
// set NSTextView string from SwiftUI-Binding
nsTextView.string = text
}
// Create Coordinator for this View
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
// Declare nested Coordinator class which conforms to NSTextViewDelegate
class Coordinator: NSObject, NSTextViewDelegate {
var parent: Representable // store reference to parent
init(_ textEditor: Representable) {
self.parent = textEditor
}
// delegate method to retrieve changed text
func textDidChange(_ notification: Notification) {
// check that Notification.name is of expected notification
// cast Notification.object as NSTextView
guard notification.name == NSText.didChangeNotification,
let nsTextView = notification.object as? NSTextView else {
return
}
// set SwiftUI-Binding
parent.text = nsTextView.string
}
// Pass SwiftUI UndoManager to NSTextView
func undoManager(for view: NSTextView) -> UndoManager? {
parent.undoManger
}
// feel free to implement more delegate methods...
}
}
Usage
ContenView: View {
#State private var text: String
var body: some View {
VStack {
Text("Enter your text here:")
CustomizableTextEditor(text: $text)
.background(Color.red)
}
.frame(minWidth: 600, minHeight: 400)
}
}
Edit:
Pass reference to SwiftUI UndoManager so that default undo/redo actions are available.
Wrap NSTextView in NSScrollView so that it is scrollable. Set minSize property of NSTextView to enclosing SwiftUIView-Size so that it fills the entire allowed space.
Caveat: Only first line of this custom TextEditor is clickable to enable text editing.
This works for me on macOS
extension NSTextView {
open override var frame: CGRect {
didSet {
backgroundColor = .clear
drawsBackground = true
}
}
}
struct ContentView: View {
#State var text = ""
var body: some View {
TextEditor(text: $text)
.background(Color.red)
}
Reference this answer
To achieve this visual design here is the code I used.
iOS 16
TextField(
"free_form",
text: $comment,
prompt: Text("Type your feedback..."),
axis: .vertical
)
.lineSpacing(10.0)
.lineLimit(10...)
.padding(16)
.background(Color.themeSeashell)
.cornerRadius(16)
iOS 15
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 16)
.foregroundColor(.gray)
TextEditor(text: $comment)
.padding()
.focused($isFocused)
if !isFocused {
Text("Type your feedback...")
.padding()
}
}
.frame(height: 132)
.onAppear() {
UITextView.appearance().backgroundColor = .clear
}
You can use Mojtaba's answer (the approved answer). It works in most cases. However, if you run into this error:
"Return from initializer without initializing all stored properties"
when trying to use the init{ ... } method, try adding UITextView.appearance().backgroundColor = .clear to .onAppear{ ... } instead.
Example:
var body: some View {
VStack(alignment: .leading) {
...
}
.onAppear {
UITextView.appearance().backgroundColor = .clear
}
}
Using the Introspect library, you can use .introspectTextView for changing the background color.
TextEditor(text: .constant("Placeholder"))
.cornerRadius(8)
.frame(height: 100)
.introspectTextView { textView in
textView.backgroundColor = UIColor(Color.red)
}
Result
import SwiftUI
struct AddCommentView: View {
init() {
UITextView.appearance().backgroundColor = .clear
}
var body: some View {
VStack {
if #available(iOS 16.0, *) {
TextEditor(text: $viewModel.commentText)
.scrollContentBackground(.hidden)
} else {
TextEditor(text: $viewModel.commentText)
}
}
.background(Color.blue)
.frame(height: UIScreen.main.bounds.width / 2)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(Color.red, lineWidth: 1)
)
}
}
It appears the UITextView.appearance().backgroundColor = .clear trick in IOS 16,
only works for the first time you open the view and the effect disappear when the second time it loads.
So we need to provide both ways in the app. Answer from StuFF mc works.
var body: some View {
if #available(iOS 16.0, *) {
mainView.scrollContentBackground(.hidden)
} else {
mainView.onAppear {
UITextView.appearance().backgroundColor = .clear
}
}
}
// rename body to mainView
var mainView: some View {
TextEditor(text: $notes).background(Color.red)
}

Bounce animation is working only after second tap [SwiftUI]

I am triggering animation after unhiding the view unfortunately animation is not working unless I tap twice
struct ContentView: View {
#State var animate = false
#State var isViewHidden: Bool = true
var body: some View {
VStack {
ZStack {
Circle()
.fill(.blue).opacity(0.25).frame(width: 40, height: 40).offset(y: self.animate ? 0 : 60)
.hides(isViewHidden)
}
.animation((Animation.linear(duration: 1.5).repeatForever(autoreverses: true))
, value: self.animate ? 0 : 60)
Spacer()
Button("Tap here") {
self.isViewHidden = false
self.animate.toggle()
}
}
.padding()
}
}
extension View {
#ViewBuilder
func hides(_ isHidden: Bool) -> some View {
if isHidden {
hidden()
} else {
self
}
}
}
SwiftUI uses a before view and an after view to animate. You are introducing the view to animate at the same time you are updating self.animate, so Swift doesn't have a before view to use for the animation.
Change your View extension to this:
extension View {
#ViewBuilder
func hides(_ isHidden: Bool) -> some View {
self.opacity(isHidden ? 0 : 1)
}
}
This leaves the view onscreen at all times, but just hides it by making it invisible. That way, the view is there to animate from the start.

Blur a view in SwiftUI based on condition

Is there a way to conditionally blur a view in SwiftUI?
For example for VStack:
VStack {
//contents
}.blur(radius: 20)
Would blur the view. However, I'd like to blur it based on a condition.
I could use an if statement but that would mean writing the view twice in code like so:
if(shouldBlur)
{
VStack {
//contents
}.blur(radius: 20)
}
else
{
VStack {
//contents
}
}
Is this the only way to achieve the desired outcome?
We can just use 0 (no blur), like
VStack {
//contents
}.blur(radius: shouldBlur ? 20 : 0) // << here !!
There are a few different methods you could use. Methods 2 and 3 involve some duplication, but you can abstract that out of your direct view hierarchy.
struct ContentView: View {
#State private var isBlurred = false
var body: some View {
VStack {
Button("Toggle") { isBlurred.toggle() }
Text("Sample 1").blur(radius: isBlurred ? 20 : 0)
Text("Sample 2").modifier(ConditionalBlurred(isBlurred: isBlurred, radius: 20))
Text("Sample 3").conditionalBlur(isBlurred: isBlurred, radius: 20)
}
}
}
struct ConditionalBlurred: ViewModifier {
var isBlurred: Bool
var radius: CGFloat
func body(content: Content) -> some View {
if isBlurred {
content.blur(radius: radius)
} else {
content
}
}
}
extension View {
#ViewBuilder func conditionalBlur(isBlurred: Bool, radius: CGFloat) -> some View {
if isBlurred {
self.blur(radius: radius)
} else {
self
}
}
}
You can just store Views in variables, like so:
var viewToBlur: some View = {
//your View
}
And then use the same View twice in your body, like so:
if(shouldBlur)
{
VStack {
viewToBlur
}.blur(radius: 20)
}
else
{
VStack {
viewToBlur
}
}
And if your View needs dynamic properties you can use functions that return AnyView, like so:
func viewToBlur() -> AnyView{
return AnyView(
//your View
)
}

Move Index View above home indicator in Tab View

I have reached an annoying issue with SwiftUI. I have a horizontal pager with vertical scroll views as pages. It is defined as simple as they come,
TabView(selection: $selected) {
ForEach(focus!.list.things) { thing in
FullView(thing: thing).tag(thing)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
and
struct FullView: View {
let thing: Thing
var body: some View {
ScrollView {
VStack {
...
}
}
}
}
This produces a view which does what I want, except it does not reach all the way down below the home indicator.
I can solve this by adding .ignoresSafeArea(edges: .bottom) to the TabView, but that produces another displeasing result where the page indicator collides with the home indicator.
Is there any reasonable way accomplish full height vertical scroll while keeping the index page indicator above the home indicator?
Code to recreate issue:
struct ContentView: View {
#State var isSheetUp = false
var body: some View {
Button("Present") {
isSheetUp.toggle()
}
.sheet(isPresented: $isSheetUp) {
Sheet()
}
}
struct Sheet: View {
var body: some View {
NavigationView {
TabView() {
Page()
Page()
Page()
}
// Comment this to switch layout issue
.ignoresSafeArea(edges: .bottom)
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.navigationTitle("Title")
.navigationBarTitleDisplayMode(.inline)
}
}
}
struct Page: View {
var body: some View {
ScrollView {
VStack {
Rectangle()
.foregroundColor(.teal)
.padding()
.frame(minHeight: 10000)
}
}.background(Color.brown)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
EDIT: See also #nekno's fantastic additions!
This is possible if you create a custom UIPageControl, manually tag each tab in the TabView, and make sure to keep track of the numberOfPages:
struct PageControlView: UIViewRepresentable {
#Binding var currentPage: Int
#Binding var numberOfPages: Int
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .prominent
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
}
}
struct ContentView: View {
#State var isSheetUp = false
var body: some View {
Button("Present") {
isSheetUp.toggle()
}
.sheet(isPresented: $isSheetUp) {
Sheet()
}
}
struct Sheet: View {
#State var currentPage = 0
#State var numberOfPages = 3
var body: some View {
NavigationView {
ZStack {
TabView(selection: $currentPage) {
Page().tag(0)
Page().tag(1)
Page().tag(2)
}
// Comment this to switch layout issue
.ignoresSafeArea(edges: .bottom)
.tabViewStyle(.page(indexDisplayMode: .never))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.navigationTitle("Title")
.navigationBarTitleDisplayMode(.inline)
VStack {
Spacer()
PageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
}
}
}
}
}
struct Page: View {
var body: some View {
ScrollView {
VStack {
Rectangle()
.foregroundColor(.teal)
.padding()
.frame(minHeight: 10000)
}
}.background(Color.brown)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#Coder-256's answer set me on the right path, and I added a couple enhancements you might find useful.
The UIPageControl normally iterates through the pages when you tap on it. As written, the indicator in the page control was changing, but the pages weren't actually changing, so I added a target for the page control's .valueChanged event.
When setting the current page based on the new changed value, wrapping the assignment in a withAnimation closure ensure the page animates to the next page, otherwise it just replaces the current page instantaneously.
TabView will work with any valid tag values, which just need to conform to Hashable.
To work with the page control, you need those tag values to be convertible to Int values, but it's common practice to use a strongly-typed, named value for tags, so I added support for an enum that conforms to RawRepresentable with a backing type of Int.
Others may find it easier to just use hard-coded integers for the tag values, so if you ever reordered the pages in your TabView you wouldn't have to remember to reorder the cases in your enum, but to each their own.
The UIPageControl and its parent ViewHost that hosts the UIViewRepresentable instance both have auto resizing masks that result in their frames expanding to consume the horizontal space of the containing superview.
Both the page control and the view host participate in hit testing, so they intercept touches to the left and right of the page control when you actually intend to scroll the content underneath.
Adding the allowsHitTesting(false) view modifier eliminates that behavior, but also disables all interaction with the page control, so it breaks the tap/paging functionality.
I played around with various solutions, and the easiest seems to be to just set a frame on the page control that requests a maxWidth and maxHeight of 0, and as a result the view shrinks to its intrinsic content size.
struct PageControlView<T: RawRepresentable>: UIViewRepresentable where T.RawValue == Int {
#Binding var currentPage: T
#Binding var numberOfPages: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .prominent
uiView.currentPage = currentPage.rawValue
uiView.numberOfPages = numberOfPages
uiView.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged), for: .valueChanged)
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage.rawValue
uiView.numberOfPages = numberOfPages
}
}
extension PageControlView {
final class Coordinator: NSObject {
var parent: PageControlView
init(_ parent: PageControlView) {
self.parent = parent
}
#objc func valueChanged(sender: UIPageControl) {
guard let currentPage = T(rawValue: sender.currentPage) else {
return
}
withAnimation {
parent.currentPage = currentPage
}
}
}
}
struct ContentView: View {
#State private var currentPage: Pages = .myFirstPage
#State private var numberOfPages = Pages.allCases.count
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $currentPage) {
MyFirstPage()
.tag(Pages.myFirstPage)
MySecondPage()
.tag(Pages.mySecondPage)
MyThirdPage()
.tag(Pages.myThirdPage)
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
UIPageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
.frame(maxWidth: 0, maxHeight: 0)
.padding(22) // 22 seems to mimic SwiftUI's `PageIndexView` placement from the bottom edge
}
}
}
extension ContentView {
enum Pages: Int, CaseIterable {
case myFirstPage
case mySecondPage
case myThirdPage
}
}

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