SwiftUI聊天应用程序:反转列表和上下文菜单的困扰

7
我正在使用SwiftUI构建一个聊天应用程序。为了在聊天中显示消息,我需要一个反向列表(最近的条目显示在底部,并自动滚动到底部)。我通过翻转列表及其每个条目(标准方法)来创建反向列表。
现在我想在消息中添加上下文菜单。但是在长按后,菜单显示的是翻转的消息。这很有道理,因为它从列表中取出了翻转的消息。
你对如何解决这个问题有什么想法吗?

enter image description here

import SwiftUI

struct TestView: View {
    var arr = ["1aaaaa","2bbbbb", "3ccccc", "4aaaaa","5bbbbb", "6ccccc", "7aaaaa","8bbbbb", "9ccccc", "10aaaaa","11bbbbb", "12ccccc"]

    var body: some View {
        List {
            ForEach(arr.reversed(), id: \.self) { item in
                VStack {
                    Text(item)
                        .height(100)
                        .scaleEffect(x: 1, y: -1, anchor: .center)
                }
                .contextMenu {
                    Button(action: { }) {
                        Text("Reply")
                    }
                }
            }
        }
        .scaleEffect(x: 1, y: -1, anchor: .center)

    }
}

struct TestView_Previews: PreviewProvider {
    static var previews: some View {
        TestView()
    }
}

编辑:将代码更改为明确表示数组是倒序的。请仅返回翻译后的文本。 - Arman
你尝试过将scaleEffect应用于contextMenu吗? - Alexander Ushakov
@AlexanderUshakov 是的,没有起作用。 - Arman
只是一个想法,对于每个消息如何使用ZStack,在翻转视图下方添加非翻转视图,并将上下文菜单附加到其中。您可能需要调整触摸以使其穿过顶部视图。我认为这应该可以实现。 - alionthego
5个回答

3

翻转的问题在于需要翻转上下文菜单,但SwiftUI并没有给予这么多控制权。

更好的解决方法是获取嵌入的UITableView(您将拥有更多的控制权),而且您不需要添加额外的hack。

进入图像描述

以下是演示代码:

import SwiftUI
import UIKit
struct TestView: View {
    @State var arr = ["1aaaaa","2bbbbb", "3ccccc", "4aaaaa","5bbbbb", "6ccccc", "7aaaaa","8bbbbb", "9ccccc", "10aaaaa","11bbbbb", "12ccccc"]

    @State var tableView: UITableView? {
        didSet {
            self.tableView?.adaptToChatView()

            DispatchQueue.main.asyncAfter(deadline: .now()) {
                self.tableView?.scrollToBottom(animated: true)
            }
        }
    }

    var body: some View {
        NavigationView {
        List {
            UIKitView { (tableView) in
                DispatchQueue.main.async {
                    self.tableView = tableView
                }
            }
            ForEach(arr, id: \.self) { item in
                Text(item).contextMenu {
                    Button(action: {
                        // change country setting
                    }) {
                        Text("Choose Country")
                        Image(systemName: "globe")
                    }

                    Button(action: {
                        // enable geolocation
                    }) {
                        Text("Detect Location")
                        Image(systemName: "location.circle")
                    }
                }
            }
        }
        .navigationBarTitle(Text("Chat View"), displayMode: .inline)
            .navigationBarItems(trailing:
          Button("add chat") {
            self.arr.append("new Message: \(self.arr.count)")

            self.tableView?.adaptToChatView()

            DispatchQueue.main.async {
                self.tableView?.scrollToBottom(animated: true)
            }

          })

        }

    }
}


extension UITableView {
    func adaptToChatView() {
        let offset = self.contentSize.height - self.visibleSize.height
        if offset < self.contentOffset.y {
            self.tableHeaderView = UIView.init(frame: CGRect.init(x: 0, y: 0, width: self.contentSize.width, height: self.contentOffset.y - offset))
        }
    }
}


extension UIScrollView {
    func scrollToBottom(animated:Bool) {
        let offset = self.contentSize.height - self.visibleSize.height
        if offset > self.contentOffset.y {
            self.setContentOffset(CGPoint(x: 0, y: offset), animated: animated)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        TestView()
    }
}



final class UIKitView : UIViewRepresentable {
    let callback: (UITableView) -> Void //return TableView in CallBack

    init(leafViewCB: @escaping ((UITableView) -> Void)) {
      callback = leafViewCB
    }

    func makeUIView(context: Context) -> UIView  {
        let view = UIView.init(frame: CGRect(x: CGFloat.leastNormalMagnitude,
        y: CGFloat.leastNormalMagnitude,
        width: CGFloat.leastNormalMagnitude,
        height: CGFloat.leastNormalMagnitude))
        view.backgroundColor = .clear
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {


        if let tableView = uiView.next(UITableView.self) {
            callback(tableView) //return tableview if find
        }
    }
}
extension UIResponder {
    func next<T: UIResponder>(_ type: T.Type) -> T? {
        return next as? T ?? next?.next(type)
    }
}

谢谢。这个方案可行,将一种“隐形嗅探器”添加到SwiftUI视图中是一个好主意。我不得不做一些更改以使其不会出现故障,特别是更改scrollToBottom()并在异步调用中添加额外的延迟。 - Arman
adaptToChatView()有什么作用?我把它移除了,一切都正常。 - Arman
如果您有一些消息并且想要在顶部填充而不是底部,则需要进行适应。如果不需要,可以忽略。这个想法是使用UITableView。希望我的答案有所帮助。干杯。 - Prafulla

2
从iOS 14开始,SwiftUI有了ScrollViewReader,可以用来定位滚动。使用minHeight和Spacer()的GeometryReader可以创建一个VStack,它使用整个屏幕并从底部开始显示消息。项目按照通常的先进先出顺序从数组中读取和追加。

enter image description here

SwiftUI 示例:
struct ContentView: View {
    @State var items: [Item] = []
    @State var text: String = ""
    @State var targetItem: Item?
    
    var body: some View {
        VStack {
            ScrollViewReader { scrollView in
                ChatStyleScrollView() {
                    ForEach(items) { item in
                        ItemView(item: item)
                        .id(item.id)
                    }
                }
                .onChange(of: targetItem) { item in
                    if let item = item {
                        withAnimation(.default) {
                            scrollView.scrollTo(item.id)
                        }
                    }
                }
                TextEntryView(items: $items, text: $text, targetItem: $targetItem)
            }
        }
    }
}

//MARK: - Item Model with unique identifier
struct Item: Codable, Hashable, Identifiable {
    var id: UUID
    var text: String
}

//MARK: - ScrollView that pushes text to the bottom of the display
struct ChatStyleScrollView<Content: View>: View {
    let content: Content
    
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    var body: some View {
        GeometryReader { proxy in
            ScrollView(.vertical, showsIndicators: false) {
                VStack {
                    Spacer()
                    content
                }
                .frame(minHeight: proxy.size.height)
            }
        }
    }
}

//MARK: - A single item and its layout
struct ItemView: View {
    var item: Item
    
    var body: some View {
        HStack {
            Text(item.text)
                .frame(height: 100)
                .contextMenu {
                    Button(action: { }) {
                        Text("Reply")
                    }
                }
            Spacer()
        }
    }
}

//MARK: - TextField and Send button used to input new items
struct TextEntryView: View {
    @Binding var items: [Item]
    @Binding var text: String
    @Binding var targetItem: Item?

    var body: some View {
        HStack {
            TextField("Item", text: $text)
                .frame(height: 44)
            Button(action: send) { Text("Send") }
        }
        .padding(.horizontal)
    }
    
    func send() {
        guard !text.isEmpty else { return }
        let item = Item(id: UUID(), text: text)
        items.append(item)
        text = ""
        targetItem = item
    }
}

1
你可以创建一个自定义的模态框来回复,并通过长按列表中的每个元素来显示它,而不显示contextMenu
@State var showYourCustomReplyModal = false
@GestureState var isDetectingLongPress = false
var longPress: some Gesture {
    LongPressGesture(minimumDuration: 0.5)
        .updating($isDetectingLongPress) { currentstate, gestureState,
                transaction in
            gestureState = currentstate
        }
        .onEnded { finished in
            self.showYourCustomReplyModal = true
        }
}

像这样应用:

        ForEach(arr, id: \.self) { item in
            VStack {
                Text(item)
                    .height(100)
                    .scaleEffect(x: 1, y: -1, anchor: .center)
            }.gesture(self.longPress)
        }

谢谢。是的,我可以这样做,实际上重新实现上下文菜单。但我希望不必这样做。 - Arman

0
如果有人在UIKit中寻找解决方案:不要使用cell,而应该将contentViewcontentView的子视图作为UITargetedPreview的参数。像这样:
extension CustomScreen: UITableViewDelegate {
    func tableView(_ tableView: UITableView,
                   contextMenuConfigurationForRowAt indexPath: IndexPath,
                   point: CGPoint) -> UIContextMenuConfiguration? {
        UIContextMenuConfiguration(identifier: indexPath as NSCopying,
                                   previewProvider: nil) { _ in
            // ...
            return UIMenu(title: "", children: [/* actions */])
        }
    }

    func tableView(
        _ tableView: UITableView,
        previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration
    ) -> UITargetedPreview? {
        getTargetedPreview(for: configuration.identifier as? IndexPath)
    }

    func tableView(
        _ tableView: UITableView,
        previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration
    ) -> UITargetedPreview? {
        getTargetedPreview(for: configuration.identifier as? IndexPath)
    }
}


extension CustomScreen {
    private func getTargetedPreview(for indexPath: IndexPath?) -> UITargetedPreview? {
        guard let indexPath = indexPath,
              let cell = tableView.cellForRow(at: indexPath) as? CustomTableViewCell else { return nil }

        return UITargetedPreview(view: cell.contentView,
                                 parameters: UIPreviewParameters().then { $0.backgroundColor = .clear })
    }
}

-1

如果我理解正确的话,为什么不在“for each”循环或之前对数组进行排序呢?这样你就不必使用任何“scaleEffect”了。稍后如果你获得了你的消息对象,你可能已经将一个日期分配给它,因此你可以按日期排序。在你上面的情况下,你可以使用:

ForEach(arr.reverse(), id: \.self) { item in

...
}

这将在顶部打印出 12ccccc 作为第一条消息,1aaaaa 作为最后一条消息。


谢谢。数组已经是倒序的了。问题在于没有办法通过编程方式将ListView滚动到底部,而这正是聊天应用程序所需要的。因此需要使用flips。 - Arman

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接