¿Cómo hacer que una lista SwiftUI se desplace automáticamente?
Al agregar contenido a mi ListView, quiero que se desplace hacia abajo automáticamente.
Estoy usando SwiftUI List
y BindableObject
como controlador. Se están agregando nuevos datos a la lista.
List(chatController.messages, id: \.self) { message in
MessageView(message.text, message.isMe)
}
Quiero que la lista se desplace hacia abajo a medida que agrego nuevos datos a la lista de mensajes. Sin embargo, tengo que desplazarme hacia abajo manualmente.
Actualización: en iOS 14 ahora hay una forma nativa de hacer esto. lo estoy haciendo asi
ScrollViewReader { scrollView in
ScrollView(.vertical) {
LazyVStack {
ForEach(notes, id: \.self) { note in
MessageView(note: note)
}
}
.onAppear {
scrollView.scrollTo(notes[notes.endIndex - 1])
}
}
}
Para iOS 13 y versiones anteriores, puedes probar:
Descubrí que cambiar las vistas parecía funcionar bastante bien para mí. Esto inicia ScrollView en la parte inferior y, al agregarle nuevos datos, automáticamente desplaza la vista hacia abajo.
- Girar la vista más exterior 180
.rotationEffect(.radians(.pi))
- Gíralo a lo largo del plano vertical.
.scaleEffect(x: -1, y: 1, anchor: .center)
Tendrás que hacer esto también con tus vistas internas, ya que ahora todas estarán rotadas y volteadas. Para darles la vuelta, haga lo mismo que arriba.
Si necesita tantos lugares, puede que valga la pena tener una vista personalizada para esto.
Puedes probar algo como lo siguiente:
List(chatController.messages, id: \.self) { message in
MessageView(message.text, message.isMe)
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
Aquí hay una extensión de Vista para darle la vuelta.
extension View {
public func flip() -> some View {
return self
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
}
Como no existe una función de este tipo incorporada por ahora (ni para List ni para ScrollView), Xcode 11.2, necesitaba codificar ScrollView personalizado con el comportamiento ScrollToEnd
!!! Inspirado en este artículo.
Aquí está el resultado de mis experimentos, espero que a uno también le resulte útil. Por supuesto, hay más parámetros, que pueden ser configurables, como colores, etc., pero parece trivial y fuera de alcance.
import SwiftUI
struct ContentView: View {
@State private var objects = ["0", "1"]
var body: some View {
NavigationView {
VStack {
CustomScrollView(scrollToEnd: true) {
ForEach(self.objects, id: \.self) { object in
VStack {
Text("Row \(object)").padding().background(Color.yellow)
NavigationLink(destination: Text("Details for \(object)")) {
Text("Link")
}
Divider()
}.overlay(RoundedRectangle(cornerRadius: 8).stroke())
}
}
.navigationBarTitle("ScrollToEnd", displayMode: .inline)
// CustomScrollView(reversed: true) {
// ForEach(self.objects, id: \.self) { object in
// VStack {
// Text("Row \(object)").padding().background(Color.yellow)
// NavigationLink(destination: Text("Details for \(object)")) {
// Image(systemName: "chevron.right.circle")
// }
// Divider()
// }.overlay(RoundedRectangle(cornerRadius: 8).stroke())
// }
// }
// .navigationBarTitle("Reverse", displayMode: .inline)
HStack {
Button(action: {
self.objects.append("\(self.objects.count)")
}) {
Text("Add")
}
Button(action: {
if !self.objects.isEmpty {
self.objects.removeLast()
}
}) {
Text("Remove")
}
}
}
}
}
}
struct CustomScrollView<Content>: View where Content: View {
var axes: Axis.Set = .vertical
var reversed: Bool = false
var scrollToEnd: Bool = false
var content: () -> Content
@State private var contentHeight: CGFloat = .zero
@State private var contentOffset: CGFloat = .zero
@State private var scrollOffset: CGFloat = .zero
var body: some View {
GeometryReader { geometry in
if self.axes == .vertical {
self.vertical(geometry: geometry)
} else {
// implement same for horizontal orientation
}
}
.clipped()
}
private func vertical(geometry: GeometryProxy) -> some View {
VStack {
content()
}
.modifier(ViewHeightKey())
.onPreferenceChange(ViewHeightKey.self) {
self.updateHeight(with: $0, outerHeight: geometry.size.height)
}
.frame(height: geometry.size.height, alignment: (reversed ? .bottom : .top))
.offset(y: contentOffset + scrollOffset)
.animation(.easeInOut)
.background(Color.white)
.gesture(DragGesture()
.onChanged { self.onDragChanged($0) }
.onEnded { self.onDragEnded($0, outerHeight: geometry.size.height) }
)
}
private func onDragChanged(_ value: DragGesture.Value) {
self.scrollOffset = value.location.y - value.startLocation.y
}
private func onDragEnded(_ value: DragGesture.Value, outerHeight: CGFloat) {
let scrollOffset = value.predictedEndLocation.y - value.startLocation.y
self.updateOffset(with: scrollOffset, outerHeight: outerHeight)
self.scrollOffset = 0
}
private func updateHeight(with height: CGFloat, outerHeight: CGFloat) {
let delta = self.contentHeight - height
self.contentHeight = height
if scrollToEnd {
self.contentOffset = self.reversed ? height - outerHeight - delta : outerHeight - height
}
if abs(self.contentOffset) > .zero {
self.updateOffset(with: delta, outerHeight: outerHeight)
}
}
private func updateOffset(with delta: CGFloat, outerHeight: CGFloat) {
let topLimit = self.contentHeight - outerHeight
if topLimit < .zero {
self.contentOffset = .zero
} else {
var proposedOffset = self.contentOffset + delta
if (self.reversed ? proposedOffset : -proposedOffset) < .zero {
proposedOffset = 0
} else if (self.reversed ? proposedOffset : -proposedOffset) > topLimit {
proposedOffset = (self.reversed ? topLimit : -topLimit)
}
self.contentOffset = proposedOffset
}
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
extension ViewHeightKey: ViewModifier {
func body(content: Content) -> some View {
return content.background(GeometryReader { proxy in
Color.clear.preference(key: Self.self, value: proxy.size.height)
})
}
}
iOS 14/15:
Lo hice usando el onChange
modificador de ScrollView así:
// View
struct ChatView: View {
@ObservedObject var viewModel = ChatViewModel()
@State var newText = ""
var body: some View {
ScrollViewReader { scrollView in
VStack {
ScrollView(.vertical) {
VStack {
ForEach(viewModel.messages) { message in
VStack {
Text(message.text)
Divider()
}
}
}.id("ChatScrollView")
}.onChange(of: viewModel.messages) { _ in
withAnimation {
scrollView.scrollTo("ChatScrollView", anchor: .bottom)
}
}
Spacer()
VStack {
TextField("Enter message", text: $newText)
.padding()
.frame(width: 400, height: 40, alignment: .center)
Button("Send") {
viewModel.addMessage(with: newText)
}
.frame(width: 400, height: 80, alignment: .center)
}
}
}
}
}
// View Model
class ChatViewModel: ObservableObject {
@Published var messages: [Message] = [Message]()
func addMessage(with text: String) {
messages.append(Message(text: text))
}
}
// Message Model
struct Message: Hashable, Codable, Identifiable {
var id: String = UUID().uuidString
var text: String
}