'SwiftUI chat app: the woes of reversed List and Context Menu

I am building a chat app in SwiftUI. To show messages in a chat, I need a reversed list (the one that shows most recent entries at the bottom and auto-scrolls to the bottom). I made a reversed list by flipping both the list and each of its entries (the standard way of doing it).

Now I want to add Context Menu to the messages. But after the long press, the menu shows messages flipped. Which I suppose makes sense since it plucks a flipped message out of the list.

Any thoughts on how to get this to work?

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()
    }
}


Solution 1:[1]

The issue with flipping is that you need to flip the context menu and SwiftUI does not give this much control.

The better way to handle this is to get access to embedded UITableView(on which you will have more control) and you need not add additional hacks.

enter image description here

Here is the demo code:

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

Solution 2:[2]

You can create a custom modal for reply and show it with long press on every element of the list without showing 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
        }
}

Apply it like:

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

Solution 3:[3]

If someone is searching for a solution in UIKit: instead of the cell, you should use the contentView or a subview of the contentView as a paramterer for the UITargetedPreview. Like this:

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

Solution 4:[4]

If I understood it correctly, why don't you order your array in the for each loop or prior. Then you do not have to use any scaleEffect at all. Later if you get your message object, you probably have a Date assinged to it, so you can order it by the date. In your case above you could use:

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

...
}

Which will print 12ccccc as first message at the top, and 1aaaaa as last message.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Md. Yamin Mollah
Solution 3 jason d
Solution 4 davidev