SwiftUI - ScrollView: onChange -> scrollTo cuts off text - swift

Currently, I have a ScrollView with this code:
ScrollView {
ScrollViewReader { scrollView in
VStack {
ForEach(messages, id: \.id) { message in
MessageView(message)
}
MessageInput()
}
.onChange(of: messages.count) { _ in
scrollView.scrollTo(messages.last.id)
}
.onAppear { scrollView.scrollTo(messages.last.id) }
}
}
.onAppear() works as intended, giving this view:
However, when I send a new message and .onChange is called, I get this:
MessageView() does have padding, but I don't see how that would affect it only .onChange and not .onAppear.
How can I move the view down so that the message input box is included?

If you really want input be inside scroller then, probably, you need something like the following
ScrollView {
ScrollViewReader { scrollView in
VStack {
ForEach(messages, id: \.id) { message in
MessageView(message)
}
MessageInput().id("input")
}
.onChange(of: messages.count) { _ in
scrollView.scrollTo("input", anchor: .bottom)
}
.onAppear {
scrollView.scrollTo("input", anchor: .bottom)
}
}
}

Related

How to Detect When User Reaches Bottom of the Screen and Trigger a Function in SwiftUI

What is the best way to detect when a user has scrolled to the bottom of the screen and trigger a specific function in SwiftUI?
var body: some View {
NavigationView {
VStack {
ZStack {
Text("Pokedex")
.font(.custom("Pokemon Solid", size: 25))
.foregroundColor(.yellow)
.opacity(0.3)
GeometryReader { geometry in
ScrollView {
LazyVGrid(columns: adaptiveColumns, spacing: 5) {
ForEach(viewModel.filteredPokemon) { pokemon in
NavigationLink(destination: PokemonDetailView(pokemon: pokemon)
) {
PokemonView(vm: viewModel, pokemon: pokemon)
}
}
}
.animation(.easeInOut(duration: 0.3), value: viewModel.filteredPokemon.count)
.navigationTitle("Pokedexarino")
.navigationBarTitleDisplayMode(.inline)
if geometry.frame(in: .global).maxY == geometry.safeAreaInsets.bottom {
Text("Reached the bottom")
//TODO: make load 30 at a time
}
}
}
.searchable(text: $viewModel.searchText)
}
}
}
.environmentObject(viewModel)
}
I tried to create a function but I think there may be a built in function that can do this. I tried Geometry reader.
You can add paging to your ScrollView by displaying an additional row at the bottom if more data is available, then using the .task modifier to load the next page, e.g.
ScrollView {
LazyVGrid(columns: adaptiveColumns, spacing: 5) {
ForEach(viewModel.filteredPokemon) { pokemon in
NavigationLink(destination: PokemonDetailView(pokemon: pokemon)
) {
PokemonView(vm: viewModel, pokemon: pokemon)
}
}
if viewModel.moreDataAvailable {
Text("Reached the bottom")
.task {
await viewModel.loadNextPage()
}
}
}
}

SwiftUI: Call a function of a programmatically created view

I am trying to make a SwiftUI ScrollView scroll to a certain point in an abstracted view when a button is pressed in a view which is calling the abstracted view programmatically. Here is my code:
struct AbstractedView: View {
#Namespace var view2ID
var body: some View {
ScrollView {
VStack {
View1()
View2()
.id(view2ID)
View3()
}
}
}
func scrollToView2(_ proxy: ScrollViewProxy) {
proxy.scrollTo(view2ID, anchor: .topTrailing)
}
}
As you can see, when scrollToView2() is called (in a ScrollViewReader), the AbstractedView scrolls to view2ID. I am creating a number of AbstractedView's programmatically in a different View:
struct HigherView: View {
var numAbstractedViewsToMake: Int
var body: some View {
VStack {
HStack {
ForEach (0..<numAbstractedViewsToMake, id: \.self) { _ in
AbstractedView()
}
}
Text("button")
.onTapGesture {
/* call each AbstractedView.scrollToView2()
}
}
}
}
If I stored these views in an array in a struct inside my HigherView with a ScrollViewReader for each AbstractedView would that work? I feel as though there has to be a nicer way to achieve this, I just have no clue how to do it. I am new to Swift so thank you for any help.
P.S. I have heard about UIKit but I don't know anything about it, is this the right time to be using that?
Using the comments from #Asperi and #jnpdx, I was able to come up with a more powerful solution than I needed:
class ScrollToModel: ObservableObject {
enum Action {
case end
case top
}
#Published var direction: Action? = nil
}
struct HigherView: View {
#StateObject var vm = ScrollToModel()
var numAbstractedViewsToMake: Int
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()
HStack {
ForEach(0..<numAbstractedViewsToMake, id: \.self) { _ in
ScrollToModelView(vm: vm)
}
}
}
}
}
struct AbstractedView: View {
#ObservedObject var vm: ScrollToModel
let items = (0..<200).map { $0 } // this is his demo
var body: some View {
VStack {
ScrollViewReader { sp in
ScrollView {
LazyVStack { // this bit can be changed accordingly
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
}
}
}
}
}
}
}
}
Thank you both!

ScrollView moves content over when scrolling

When using a ScrollViewReader that is passed to another view, the views content moves over when scrolling. If the ForEach is inside the same view as the reader the view shift does not happen. When you manually scroll the views fix themselves. I have posted a simplified version below.
How would I stop the view from shifting over when the view is being scrolled?
I am using Xcode13 beta 5
struct ContentView: View {
var body: some View {
VStack {
ScrollView {
ScrollViewReader { proxy in
Button("Scroll to bottom") {
withAnimation {
proxy.scrollTo(99, anchor: .bottom)
}
}
TestView(proxy: proxy)
}
}
}
}
}
struct TestView: View {
var proxy: ScrollViewProxy
var body: some View {
VStack { ForEach(1..<100) { index in
Text("Test \(index)")
.id(index)
}
Button("Scroll to top") {
withAnimation {
proxy.scrollTo(1, anchor: .top)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You can't have the views to scroll to, nested in another view.
Solution #1
Remove the VStack:
struct TestView: View {
let proxy: ScrollViewProxy
var body: some View {
ForEach(1..<100) { index in
Text("Test \(index)")
.id(index)
}
Button("Scroll to top") {
withAnimation {
proxy.scrollTo(1, anchor: .top)
}
}
}
}
Solution #2
Give the extracted view the content at the top of the ScrollView, by passing it in:
struct ContentView: View {
var body: some View {
VStack {
TestView { proxy in
Button("Scroll to bottom") {
withAnimation {
proxy.scrollTo(99, anchor: .bottom)
}
}
}
}
}
}
struct TestView<Content: View>: View {
#ViewBuilder let content: (ScrollViewProxy) -> Content
var body: some View {
ScrollView {
ScrollViewReader { proxy in
content(proxy)
ForEach(1..<100) { index in
Text("Test \(index)")
.id(index)
}
Button("Scroll to top") {
withAnimation {
proxy.scrollTo(1, anchor: .top)
}
}
}
}
}
}
I would recommend a slightly different way of doing this. If possible, it is probably best to have this in just one view. However, you want to reuse some of the components so this may not be what you are looking for.
Another thing I noticed: the scrolling doesn't go far enough to reach the buttons, which can appear underneath the safe-area and unable to be pressed. Make these changes:
Button("Scroll to bottom") {
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.id("top")
Button("Scroll to top") {
withAnimation {
proxy.scrollTo("top", anchor: .top)
}
}
.id("bottom")

Swiftui Scrollview scroll to bottom of list

I'm trying to make my view scroll to the bottom of the item list every time a new item is added.
This is my code, can someone help please?
ScrollView(.vertical, showsIndicators: false) {
ScrollViewReader{ value in
VStack() {
ForEach(0..<self.count, id:\.self) {
Text("item \($0)")
}
}.onChange(of: self.data.sampleCount, perform: value.scrollTo(-1))
}
}
We need to give id for views which in ScrollView we want to scroll to.
Here is a demo of solution. Tested with Xcode 12.1 / iOS 14.1
struct DemoView: View {
#State private var data = [String]()
var body: some View {
VStack {
Button("Add") { data.append("Data \(data.count + 1)")}
.padding().border(Color.blue)
ScrollView(.vertical, showsIndicators: false) {
ScrollViewReader{ sr in
VStack {
ForEach(self.data.indices, id:\.self) {
Text("item \($0)")
.padding().id($0) // << give id
}
}.onChange(of: self.data.count) { count in
withAnimation {
sr.scrollTo(count - 1) // << scroll to view with id
}
}
}
}
}
}
}
You can add an EmptyView item in the bottom of list
ScrollView(.vertical, showsIndicators: false) {
ScrollViewReader{ value in
VStack() {
ForEach(0..<self.count, id:\.self) {
Text("item \($0)")
}
Spacer()
.id("anID") //You can use any type of value
}.onChange(of: self.data.sampleCount, perform: { count in
value.scrollTo("anID")
})
}
Here is my example/solution.
var scrollView : some View {
ScrollView(.vertical) {
ScrollViewReader { scrollView in
ForEach(self.messagesStore.messages) { msg in
VStack {
OutgoingPlainMessageView() {
Text(msg.message).padding(.all, 20)
.foregroundColor(Color.textColorPrimary)
.background(Color.colorPrimary)
}.listRowBackground(Color.backgroundColorList)
Spacer()
}
// 1. First important thing is to use .id
//as identity of the view
.id(msg.id)
.padding(.leading, 10).padding(.trailing, 10)
}
// 2. Second important thing is that we are going to implement //onChange closure for scrollTarget change,
//and scroll to last message id
.onChange(of: scrollTarget) { target in
withAnimation {
scrollView.scrollTo(target, anchor: .bottom)
}
}
// 3. Third important thing is that we are going to implement //onChange closure for keyboardHeight change, and scroll to same //scrollTarget to bottom.
.onChange(of: keyboardHeight){ target in
if(nil != scrollTarget){
withAnimation {
scrollView.scrollTo(scrollTarget, anchor: .bottom)
}
}
}
//4. Last thing is to add onReceive clojure, that will add an action to perform when this ScrollView detects data emitted by the given publisher, in our case messages array.
// This is the place where our scrollTarget is updating.
.onReceive(self.messagesStore.$messages) { messages in
scrollView.scrollTo(messages.last!.id, anchor: .bottom)
self.scrollTarget = messages.last!.id
}
}
}
}
Also take a look at my article on medium.
https://mehobega.medium.com/auto-scrollview-using-swiftui-and-combine-framework-b3e40bb1a99f

ScrollView overlaps Text in VStack when hiding TabBar

Currently I'm developing a multi-tab app, therefore the ContentView consists of a TabView.
In the linked SecondView I want to hide the TabBar but when doing this, the contents of the ScrollView are overlapping with the content of the surrounding VStack below it.
The following code is a simplified and abstracted code of the app:
struct ContentView: View {
static var tabBar: UITabBar!
var body: some View {
TabView {
NavigationView {
NavigationLink(destination: SecondView()) {
Text("Navigate")
}
}
.tabItem { EmptyView() }
}
}
}
struct SecondView: View {
var body: some View {
VStack {
ScrollView {
ForEach(0..<50) { idx in
Text("\(idx)")
}
}
Text("Just some text so visualize the overlapping")
}
.padding(.bottom, 30)
.onAppear {
ContentView.tabBar.isHidden = true
}
.padding(.bottom, -ContentView.tabBar.frame.height)
}
}
extension UITabBar {
override open func didMoveToSuperview() {
super.didMoveToSuperview()
ContentView.tabBar = self
}
}
To be more precise this starts happening after I apply the negative padding to the VStack in order to make the free space usable.
Does anyone have an idea on how to fix this?
It is because by default Text view is transparent, so you just see scroll view content below it.
Here is a demo of possible solution
VStack {
ScrollView {
ForEach(0..<50) { idx in
Text("\(idx)")
}
}
Text("Just some text so visualize the overlapping")
.padding()
.frame(maxWidth: .infinity)
.background(Color(UIColor.systemBackground))
.edgesIgnoringSafeArea(.bottom)
}
Another possible alternate is to clip ScrollView content
ScrollView {
ForEach(0..<50) { idx in
Text("\(idx)")
}
}
.clipped()