source

스위프트 만드는 법UI 목록이 자동으로 스크롤됩니까?

ittop 2023. 9. 4. 20:37
반응형

스위프트 만드는 법UI 목록이 자동으로 스크롤됩니까?

목록 보기에 내용을 추가할 때 자동으로 아래로 스크롤합니다.

Swift UI를 사용하고 있습니다.List a 리고a.BindableObject관리자로 지정합니다.새 데이터를 목록에 추가하는 중입니다.

List(chatController.messages, id: \.self) { message in
    MessageView(message.text, message.isMe)
}

메시지 목록에 새 데이터를 추가할 때 목록이 아래로 스크롤되기를 원합니다.하지만 수동으로 아래로 스크롤해야 합니다.

업데이트: iOS 14에서는 이제 기본적인 방법으로 이를 수행이 가능합니다.그렇게 하고 있습니다.

        ScrollViewReader { scrollView in
            ScrollView(.vertical) {
                LazyVStack {
                    ForEach(notes, id: \.self) { note in
                        MessageView(note: note)
                    }
                }
                .onAppear {
                    scrollView.scrollTo(notes[notes.endIndex - 1])
                }
            }
        }

iOS 13 이하에서는 다음을 시도할 수 있습니다.

저는 보기를 뒤집는 것이 저에게 꽤 잘 맞는 것 같다는 것을 발견했습니다.이렇게 하면 맨 아래에서 ScrollView가 시작되고 새 데이터를 추가하면 보기가 자동으로 아래로 스크롤됩니다.

  1. 회전 180도 회전.rotationEffect(.radians(.pi))
  2. ..scaleEffect(x: -1, y: 1, anchor: .center)

이제 모든 뷰가 회전 및 플립되므로 내부 뷰에도 이 작업을 수행해야 합니다.위와 같은 방법으로 뒤집습니다.

이렇게 많은 장소가 필요하다면 이를 위한 사용자 정의 보기를 사용할 가치가 있습니다.

다음과 같은 방법을 사용할 수 있습니다.

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)

뒤집을 수 있는 보기 확장자가 있습니다.

extension View {
    public func flip() -> some View {
        return self
            .rotationEffect(.radians(.pi))
            .scaleEffect(x: -1, y: 1, anchor: .center)
    }
}

현재 그러한 기능이 내장되어 있지 않기 때문에(리스트용도 ScrollView용도 아님) Xcode 11.2이므로 ScrollToEnd 동작으로 사용자 정의 ScrollView를 코딩해야 했습니다.

기사에서 영감을 받았습니다.

여기 제가 실험한 결과가 있습니다. 도움이 되길 바랍니다.물론 색상 등과 같이 구성 가능한 매개 변수가 더 많지만, 사소한 것처럼 보이고 범위를 벗어납니다.

scroll to end reverse content

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:

저는 그것을 사용하여 했습니다.onChangeScrollView의 수식어는 다음과 같습니다.

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

Swift UI 2.0 - iOS 14

과 같습니다. (으로합니다.)ScrollViewReader)

scrollView.scrollTo(rowID)

Swift 위프출시함와 UI UI 2.0한 모든 파일을 할 수 .ScrollViewReader그런 다음 스크롤해야 하는 정확한 요소 위치에 액세스할 수 있습니다.

다음은 전체 데모 앱입니다.

// A simple list of messages
struct MessageListView: View {
    var messages = (1...100).map { "Message number: \($0)" }

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(messages, id:\.self) { message in
                    Text(message)
                    Divider()
                }
            }
        }
    }
}
struct ContentView: View {
    @State var search: String = ""

    var body: some View {
        ScrollViewReader { scrollView in
            VStack {
                MessageListView()
                Divider()
                HStack {
                    TextField("Number to search", text: $search)
                    Button("Go") {
                        withAnimation {
                            scrollView.scrollTo("Message number: \(search)")
                        }
                    }
                }.padding(.horizontal, 16)
            }
        }
    }
}

미리보기

Preview

iOS 13+

ScrollViewProxy라는 이 패키지는 호출할 수 있는 ScrollViewProxy를 제공하는 ScrollViewReader를 추가합니다.scrollTo(_:)사용자가 View에 제공한 모든 ID에 대해.후드 아래에서는 Introspect를 사용하여 UIScrollView를 가져옵니다.

예:

ScrollView {
    ScrollViewReader { proxy in
        Button("Jump to #8") {
            proxy.scrollTo(8)
        }

        ForEach(0..<10) { i in
            Text("Example \(i)")
                .frame(width: 300, height: 300)
                .scrollId(i)
        }
    }
}

Xcode 12부터 all new를 사용하면 다음과 같은 예제 코드를 사용할 수 있습니다.

은 당신의 아래코업수있다니로 아래 할 수 .chatController.messages그리고 결정.scrollViewProxy.scrollTo(chatController.messages.count-1).

언제 할까요?아마 스위프트에서UI의 새 기능onChange!

struct ContentView: View {
    let itemCount: Int = 100
    var body: some View {
        ScrollViewReader { scrollViewProxy in
            VStack {
                Button("Scroll to top") {
                    scrollViewProxy.scrollTo(0)
                }
                
                Button("Scroll to buttom") {
                    scrollViewProxy.scrollTo(itemCount-1)
                }
                
                ScrollView {
                    LazyVStack {
                        ForEach(0 ..< itemCount) { i in
                            Text("Item \(i)")
                                .frame(height: 50)
                                .id(i)
                        }
                    }
                }
            }
        }
    }
}

이것은 NSViewControllerRepresentable 객체 안에 NSCrollView를 감아서 macOS에서 수행할 수 있습니다(그리고 저는 UIScrollView와 UIViewControllerRepresentable을 사용하여 iOS에서도 동일하게 작동한다고 가정합니다).OS가 여전히 컨트롤의 많은 기능을 관리할 것이기 때문에 저는 이것이 여기 있는 다른 답변보다 조금 더 신뢰할 수 있다고 생각합니다.

방금 이 작업을 시작했으며, 컨텐츠 내에서 특정 행의 위치를 지정하는 등 몇 가지 작업을 더 시도할 계획입니다. 하지만 지금까지 사용한 코드는 다음과 같습니다.

import SwiftUI


struct ScrollableView<Content:View>: NSViewControllerRepresentable {
    typealias NSViewControllerType = NSScrollViewController<Content>
    var scrollPosition : Binding<CGPoint?>

    var hasScrollbars : Bool
    var content: () -> Content

    init(hasScrollbars: Bool = true, scrollTo: Binding<CGPoint?>, @ViewBuilder content: @escaping () -> Content) {
        self.scrollPosition = scrollTo
        self.hasScrollbars = hasScrollbars
        self.content = content
     }

    func makeNSViewController(context: NSViewControllerRepresentableContext<Self>) -> NSViewControllerType {
        let scrollViewController = NSScrollViewController(rootView: self.content())

        scrollViewController.scrollView.hasVerticalScroller = hasScrollbars
        scrollViewController.scrollView.hasHorizontalScroller = hasScrollbars

        return scrollViewController
    }

    func updateNSViewController(_ viewController: NSViewControllerType, context: NSViewControllerRepresentableContext<Self>) {
        viewController.hostingController.rootView = self.content()

        if let scrollPosition = self.scrollPosition.wrappedValue {
            viewController.scrollView.contentView.scroll(scrollPosition)
            DispatchQueue.main.async(execute: {self.scrollPosition.wrappedValue = nil})
        }

        viewController.hostingController.view.frame.size = viewController.hostingController.view.intrinsicContentSize
    }
}


class NSScrollViewController<Content: View> : NSViewController, ObservableObject {
    var scrollView = NSScrollView()
    var scrollPosition : Binding<CGPoint>? = nil
    var hostingController : NSHostingController<Content>! = nil
    @Published var scrollTo : CGFloat? = nil

    override func loadView() {
        scrollView.documentView = hostingController.view

        view = scrollView
     }

    init(rootView: Content) {
           self.hostingController = NSHostingController<Content>(rootView: rootView)
           super.init(nibName: nil, bundle: nil)
       }
       required init?(coder: NSCoder) {
           fatalError("init(coder:) has not been implemented")
       }

    override func viewDidLoad() {
        super.viewDidLoad()

    }
}

struct ScrollableViewTest: View {
    @State var scrollTo : CGPoint? = nil

    var body: some View {
        ScrollableView(scrollTo: $scrollTo)
        {

            Text("Scroll to bottom").onTapGesture {
                self.$scrollTo.wrappedValue = CGPoint(x: 0,y: 1000)
            }
            ForEach(1...50, id: \.self) { (i : Int) in
                Text("Test \(i)")
            }
            Text("Scroll to top").onTapGesture {
                self.$scrollTo.wrappedValue = CGPoint(x: 0,y: 0)
            }
        }
    }
}

Apple이 사용 가능한 방법을 개선할 때까지 Introspect 라이브러리를 사용하여 UITableView 참조를 얻는 다른 솔루션을 제시합니다.

struct LandmarkList: View {
    @EnvironmentObject private var userData: UserData
    @State private var tableView: UITableView?
    private var disposables = Set<AnyCancellable>()

    var body: some View {
        NavigationView {
            VStack {
                List(userData.landmarks, id: \.id) { landmark in
                    LandmarkRow(landmark: landmark)
                }
                .introspectTableView { (tableView) in
                    if self.tableView == nil {
                        self.tableView = tableView
                        print(tableView)
                    }
                }
            }
            .navigationBarTitle(Text("Landmarks"))
            .onReceive(userData.$landmarks) { (id) in
                // Do something with the table for example scroll to the bottom
                self.tableView?.setContentOffset(CGPoint(x: 0, y: CGFloat.greatestFiniteMagnitude), animated: false)
            }
        }
    }
}

여기 대화를 통해 채워지는 채팅의 메시지 배열과 같이 동적으로 데이터를 얻는 관찰된 개체에 대한 제 작업 솔루션이 있습니다.

메시지 배열 모델:

 struct Message: Identifiable, Codable, Hashable {
        
        //MARK: Attributes
        var id: String
        var message: String
        
        init(id: String, message: String){
            self.id = id
            self.message = message
        }
    }

실제 보기:

@ObservedObject var messages = [Message]()
@State private var scrollTarget: Int?

var scrollView : some View {
    ScrollView(.vertical) {
        ScrollViewReader { scrollView in
            ForEach(self.messages) { msg in
                Text(msg).id(message.id)
            }
            //When you add new element it will scroll automatically to last element or its ID
            .onChange(of: scrollTarget) { target in
                withAnimation {
                    scrollView.scrollTo(target, anchor: .bottom)
                }
            }
            .onReceive(self.$messages) { updatedMessages in
                //When new element is added to observed object/array messages, change the scroll position to bottom, or last item in observed array
                scrollView.scrollTo(umessages.id, anchor: .bottom)
                //Update the scrollTarget to current position
                self.scrollTarget = updatedChats.first!.messages.last!.message_timestamp
            }
        }
    }
}

GitHub: https://github.com/kenagt/AutoScrollViewExample 에서 완전히 작동하는 예제를 살펴 보십시오.

◦간소화할 수 있습니다...

.onChange(of: messages) { target in
                withAnimation {
                    scrollView.scrollTo(target.last?.id, anchor: .bottom)
                }
            }

많은 분들이 지적하신 대로.ScrollViewReader를 사용하여 메시지의 마지막 ID로 스크롤할 수 있습니다.그러나 ScrollView가 아래로 완전히 스크롤되지 않았습니다.다른 버전은 모든 메시지 아래에 불투명도 없이 정의된 ID를 텍스트에 넣는 것입니다.

ScrollViewReader { scrollView in
                        ScrollView {
                            LazyVStack {
                                
                                ForEach(chatVM.chatMessagesArray, id: \.self) { chat in
                                    MessageBubble(messageModel: chat)
                                }
                                
                                
                            }
                            Text("Hello").font(.caption).opacity(0).id(idString) // <= here!!
                        }
                        .onChange(of: chatVM.chatMessagesArray) { _ in
                            withAnimation {
                                scrollView.scrollTo(idString, anchor: .bottom)
                            }
                        }
                        .onAppear {
                            withAnimation {
                                scrollView.scrollTo(idString, anchor: .bottom)
        }
    }
}

이제 목록에서 자동 스크롤이 지원됩니다.

        ScrollViewReader { proxy in
            VStack {
                Button("Jump to last") {
                    proxy.scrollTo(viewModel.messageViewModels.last?.id)
                }

                List(viewModel.messageViewModels) { 
                    MessageView(viewModel: $0).id($0.id)
                }
            }
        }

      

언급URL : https://stackoverflow.com/questions/57258846/how-to-make-a-swiftui-list-scroll-automatically

반응형