It actually is keeping its position. It is just that the window that you are viewing it through has moved. If you look at the top, you can see that those rows are still the same. What you want to do is to move the row that was at the bottom up. How you do this is with a ScrollViewReader
. With the example given, it is pretty simple:
struct TestKeyboardScrollView2: View {
@State var textfield: String = ""
@FocusState private var keyboardVisible: Bool
var body: some View {
VStack {
ScrollViewReader { scroll in
ScrollView {
LazyVStack {
ForEach(1...100, id: \.self) { index in
Text("Row \(index)")
}
}
}
.onChange(of: keyboardVisible, perform: { _ in
if keyboardVisible {
withAnimation(.easeIn(duration: 1)) {
scroll.scrollTo(100) // this would be your array.count - 1,
// but you hard coded your ForEach
}
// The scroll has to wait until the keyboard is fully up
// this causes it to wait just a bit.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// this helps make it look deliberate and finished
withAnimation(.easeInOut(duration: 1)) {
scroll.scrollTo(100) // this would be your array.count - 1,
// but you hard coded your ForEach
}
}
}
})
}
TextField("Write here...", text: $textfield)
.focused($keyboardVisible)
}
}
}
edit:
I have been able to resolve this 99%. The key was being able to determine when a view was actually on screen. This was done by a combination of knowing when the view had been scrolled, and then testing each view to see if it was within the visible area of the parent. This view also keeps track of when the view is scrolling. When the scrolling ends, the view takes the last row in the list, and anchor it to the bottom of the screen. This will cause the row to lock in fully on screen. This behavior can be disable by removing the .onReceive(publisher)
. The keyboard height is also kept track of, and any time it is greater than zero, the list of on screen rows is locked, and the last row is anchored to the bottom again once the keyboard is fully up through a delay. The same thing happens in reverse when the keyboard is dismissed, and the lock is again removed when the keyboard height hits 0. The code is commented, but any question please ask.
struct ListWithSnapTo: View {
@State var messages = Message.dataArray()
@State var textfield: String = ""
@State var visibileIndex: [Int:Message] = [:]
@State private var keyboardVisible = false
@State private var readCells = true
let scrollDetector: CurrentValueSubject<CGFloat, Never>
let publisher: AnyPublisher<CGFloat, Never>
init() {
// This sets a publisher to keep track of whether the view is scrolling
let detector = CurrentValueSubject<CGFloat, Never>(0)
self.publisher = detector
.debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
.dropFirst()
.eraseToAnyPublisher()
self.scrollDetector = detector
}
var body: some View {
GeometryReader { outerProxy in
ScrollViewReader { scroll in
List {
ForEach(Array(zip(messages.indices, messages)), id: \.1) { (index, message) in
GeometryReader { geometry in
Text(message.messageText)
// These rows fill in from the bottom to the top
.onChange(of: geometry.frame(in: .named("List"))) { innerRect in
if readCells {
if isInView(innerRect: innerRect, isIn: outerProxy) {
visibileIndex[index] = message
} else {
visibileIndex.removeValue(forKey: index)
}
}
}
}
}
// The preferenceKey keeps track of the fact that the view is scrolling.
.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("List")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { scrollDetector.send($0) }
}
// This tages the list as a coordinate space I want to use in a geometry reader.
.coordinateSpace(name: "List")
.onAppear(perform: {
// Moves the view so that the cells on screen are recorded.
scroll.scrollTo(messages[0], anchor: .top)
})
// This keeps track of whether the keyboard is up or down by its actual appearance on the screen.
// The change in keyboardVisible allows the reset for the last cell to be set just above the keyboard.
// readCells is a flag that prevents the scrolling from changing the last view.
.onReceive(Publishers.keyboardHeight) { keyboardHeight in
if keyboardHeight > 0 {
keyboardVisible = true
readCells = false
} else {
keyboardVisible = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
readCells = true
}
}
}
// This keeps track of whether the view is scrolling. If it is, it waits a bit,
// and then sets the last visible message to the very bottom to snap it into place.
// Remove this if you don't want this behavior.
.onReceive(publisher) { _ in
if !keyboardVisible {
guard let lastVisibleIndex = visibileIndex.keys.max(),
let lastVisibleMessage = visibileIndex[lastVisibleIndex] else { return }
withAnimation(.easeOut) {
scroll.scrollTo(lastVisibleMessage, anchor: .bottom)
}
}
}
.onChange(of: keyboardVisible) { _ in
guard let lastVisibleIndex = visibileIndex.keys.max(),
let lastVisibleMessage = visibileIndex[lastVisibleIndex] else { return }
if keyboardVisible {
// Waits until the keyboard is up. 0.25 seconds seems to be the best wait time.
// Too early, and the last cell hides behind the keyboard.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
// this helps make it look deliberate and finished
withAnimation(.easeOut) {
scroll.scrollTo(lastVisibleMessage, anchor: .bottom)
}
}
} else {
withAnimation(.easeOut) {
scroll.scrollTo(lastVisibleMessage, anchor: .bottom)
}
}
}
TextField("Write here...", text: $textfield)
}
}
.navigationTitle("Scrolling List")
}
private func isInView(innerRect:CGRect, isIn outerProxy:GeometryProxy) -> Bool {
let innerOrigin = innerRect.origin.y
// This is an estimated row height based on the height of the contents plus a basic amount for the padding, etc. of the List
// Have not been able to determine the actual height of the row. This may need to be adjusted.
let rowHeight = innerRect.height + 22
let listOrigin = outerProxy.frame(in: .global).origin.y
let listHeight = outerProxy.size.height
if innerOrigin + rowHeight < listOrigin + listHeight && innerOrigin > listOrigin {
return true
}
return false
}
}
extension Notification {
var keyboardHeight: CGFloat {
return (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height ?? 0
}
}
extension Publishers {
static var keyboardHeight: AnyPublisher<CGFloat, Never> {
let willShow = NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification)
.map { $0.keyboardHeight }
let willHide = NotificationCenter.default.publisher(for: UIApplication.keyboardWillHideNotification)
.map { _ in CGFloat(0) }
return MergeMany(willShow, willHide)
.eraseToAnyPublisher()
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
struct Message: Identifiable, Hashable {
let id = UUID()
let messageText: String
let date = Date()
static func dataArray() -> [Message] {
var messArray: [Message] = []
for i in 1...100 {
messArray.append(Message(messageText: "message \(i.description)"))
}
return messArray
}
}