6

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()
    }
}
Arman
  • 1,208
  • 1
  • 12
  • 17

5 Answers5

3

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)
    }
}
Prafulla
  • 741
  • 7
  • 11
  • Thank you. This works, it's a great idea to add a sort of "invisible sniffer" into SwiftUI Views. I had to make a few changes to make it non-glitchy, in particular change [scrollToBottom()](https://stackoverflow.com/a/57011005/1526316) and add an extra delay to the async call. – Arman May 12 '20 at 07:07
  • What's the point of adaptToChatView()? I removed it and everything is fine without it. – Arman May 12 '20 at 07:13
  • Adapt is needed when you have some messages and want to pad top not bottom. If not needed you can ignore. The idea is using UITableView. Hope my answer helped. Cheers. – Prafulla May 12 '20 at 09:30
2

As of iOS 14, SwiftUI has ScrollViewReader which can be used to position the scrolling. GeometryReader along with minHeight and Spacer() can make a VStack that uses the full screen while displaying messages starting at the bottom. Items are read from and appended to an array in the usual first-in first-out order.

enter image description here

SwiftUI example:

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
    }
}
Marcy
  • 4,611
  • 2
  • 34
  • 52
1

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)
        }
Md. Yamin Mollah
  • 1,609
  • 13
  • 26
  • Thank you. Yes, I can do that in effect re-implementing Context Menu. I was hoping not to have to do that. – Arman May 12 '20 at 04:17
0

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 })
    }
}
jason d
  • 416
  • 1
  • 5
  • 10
-1

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.

davidev
  • 7,694
  • 5
  • 21
  • 56
  • Thanks. The array is already in reverse order. The problem is that there is no way to programmatically scroll the ListView to the bottom, which is what you need in a chat app. Hence the need for flips. (I edited the code in the post to make it clear that the array is reversed.) – Arman May 12 '20 at 04:15