How to extend tappable area to whole frame in SwiftUI (iOS14)? - swiftui

I am trying to make a VGrid with Swift 5.3, but the only tappable area is the upper part of the rectangle. Other answers suggest contentShape, but I am unable to make that work either. How to make the whole frame tappable? Code below:
import SwiftUI
import Combine
import Foundation
struct Item: Codable, Identifiable, Equatable {
var id: Int
var name: String
}
final class UserData: ObservableObject {
#Published var items = Bundle.main.decode([Item].self, from: "data.json")
}
struct ContentView: View {
#State var itemID = Item.ID()
#StateObject var userData = UserData()
let columns = [
GridItem(.adaptive(minimum: 118))
]
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(userData.items) { item in
NavigationLink(destination: ContentDetail(itemID: item.id - 1)) {
ContentRow(item: item)
}
}
}
}
}
}
}
struct ContentRow: View {
var item: Item
var body: some View {
VStack {
GeometryReader { geo in
ZStack{
VStack(alignment: .trailing) {
Text(item.name)
.font(.caption)
}
}
.padding()
.foregroundColor(Color.primary)
.frame(width: geo.size.width, height: 120)
.border(Color.primary, width: 2)
.cornerRadius(5)
.contentShape(Rectangle())
}
}
}
}
struct ContentDetail: View {
#State var itemID = Item.ID()
#StateObject var userData = UserData()
var body: some View {
Text(userData.items[itemID].name)
}
}
extension Bundle {
func decode<T: Decodable>(_ type: T.Type, from file: String, dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .deferredToDate, keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) -> T {
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("Failed to locate \(file) in bundle.")
}
guard let data = try? Data(contentsOf: url) else {
fatalError("Failed to load \(file) from bundle.")
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = dateDecodingStrategy
decoder.keyDecodingStrategy = keyDecodingStrategy
do {
return try decoder.decode(T.self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
fatalError("Failed to decode \(file) from bundle due to missing key '\(key.stringValue)' not found – \(context.debugDescription)")
} catch DecodingError.typeMismatch(_, let context) {
fatalError("Failed to decode \(file) from bundle due to type mismatch – \(context.debugDescription)")
} catch DecodingError.valueNotFound(let type, let context) {
fatalError("Failed to decode \(file) from bundle due to missing \(type) value – \(context.debugDescription)")
} catch DecodingError.dataCorrupted(_) {
fatalError("Failed to decode \(file) from bundle because it appears to be invalid JSON")
} catch {
fatalError("Failed to decode \(file) from bundle: \(error.localizedDescription)")
}
}
}
And the JSON part:
[
{
"id": 1,
"name": "Example data",
},
{
"id": 2,
"name": "Example data 2",
}
]
Any help is appreciated. Could this be a bug in SwiftUI?

You could simply remove the GeometryReader since you set the height anyway:
struct ContentRow: View {
var item: Item
var body: some View {
VStack {
ZStack{
VStack(alignment: .trailing) {
Text(item.name)
.font(.caption)
}
}
.padding()
.foregroundColor(Color.primary)
.frame(width: 120, height: 120)
.border(Color.primary, width: 2)
.cornerRadius(5)
.background(Color.red)
}
}
}

Try putting the contentShape on the outermost VStack of ContentRow. You need to put the contentShape on the view that is expanding to fill its parent (or on its parent), which in your case I think is the GeometryReader. The views inside the GeometryReader all shrink to fit their contents, so your contentShape rectangle doesn’t help there.

Related

SwiftUI - modifiying variable foreach on View before onTapGesture()

Foreach on view must be presented with a View to process.
struct Home : View {
private var numberOfImages = 3
#State var isPresented : Bool = false
#State var currentImage : String = ""
var body: some View {
VStack {
TabView {
ForEach(1..<numberOfImages+1, id: \.self) { num in
Image("someimage")
.resizable()
.scaledToFill()
.onTapGesture() {
currentImage = "top_00\(num)"
isPresented.toggle()
}
}
}.fullScreenCover(isPresented: $isPresented, content: {FullScreenModalView(imageName: currentImage) } )
}
}
I'm trying to display an image in fullScreenCover. My problem is that the first image is empty. Yes, we can solve this defining at the beginning, however, this will complicate the code according to my experiences.
My question is, is it possible to assign a value to currentImage before the onTapGesture processed.
In short, what is the good practice here.
What you need is to use this modifier to present your full screen modal:
func fullScreenCover<Item, Content>(item: Binding<Item?>, onDismiss: (() -> Void)? = nil, content: #escaping (Item) -> Content) -> some View where Item : Identifiable, Content : View
You pass in a binding to an optional and uses the non optional value to construct a destination:
struct ContentView: View {
private let imageNames = ["globe.americas.fill", "globe.europe.africa.fill", "globe.asia.australia.fill"]
#State var selectedImage: String?
var body: some View {
VStack {
TabView {
ForEach(imageNames, id: \.self) { imageName in
Image(systemName: imageName)
.resizable()
.scaledToFit()
.padding()
.onTapGesture() {
selectedImage = imageName
}
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
.fullScreenCover(item: $selectedImage) { imageName in
Destination(imageName: imageName)
}
}
}
}
struct Destination: View {
let imageName: String
var body: some View {
ZStack {
Color.blue
Image(
systemName: imageName
)
.resizable()
.scaledToFit()
.foregroundColor(.green)
}
.edgesIgnoringSafeArea(.all)
}
}
You will have to make String identifiable for this example to work (not recommended):
extension String: Identifiable {
public var id: String { self }
}
Building upon #LuLuGaGa’s answer (accept that answer, not this one), instead of making String Identifiable, create a new Identifiable struct to hold the image info. This guarantees each image will have a unique identity, even if they use the same base image name. Also, the ForEach loop now becomes ForEach(imageInfos) since the array contains Identifiable objects.
Use map to turn image name strings into [ImageInfo] by calling the ImageInfo initializer with each name.
This example also puts the displayed image into its own view which can be dismissed with a tap.
import SwiftUI
struct ImageInfo: Identifiable {
let name: String
let id = UUID()
}
struct ContentView: View {
private let imageInfos = [
"globe.americas.fill",
"globe.europe.africa.fill",
"globe.asia.australia.fill"
].map(ImageInfo.init)
#State var selectedImage: ImageInfo?
var body: some View {
VStack {
TabView {
ForEach(imageInfos) { imageInfo in
Image(systemName: imageInfo.name)
.resizable()
.scaledToFit()
.padding()
.onTapGesture() {
selectedImage = imageInfo
}
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
.fullScreenCover(item: $selectedImage) { imageInfo in
ImageDisplay(info: imageInfo)
}
}
}
}
struct ImageDisplay: View {
let info: ImageInfo
#Environment(\.dismiss) var dismiss
var body: some View {
ZStack {
Color.blue
Image(
systemName: info.name
)
.resizable()
.scaledToFit()
.foregroundColor(.green)
}
.edgesIgnoringSafeArea(.all)
.onTapGesture {
dismiss()
}
}
}

get the right index onTapGesture with AsyncImage

While iterating over images and loading AsyncImages, the .onTapGesture does not refer to the clicked element.
Is this due to View refresh on image loading? How to bypass this issue?
var images: [String] = [
"https://www.trinum.com/ibox/ftpcam/small_montgenevre_eglise.jpg",
"https://www.trinum.com/ibox/ftpcam/small_montgenevre_brousset.jpg",
"https://www.trinum.com/ibox/ftpcam/small_montgenevre_sommet-ts-crete.jpg",
"https://www.trinum.com/ibox/ftpcam/mega_mtgenevre_sommet-des-gondrans.jpg"
]
struct thumbnail: View {
#State var mainImageUrl: String = images[0];
var body: some View {
VStack {
AsyncImage(url: URL(string: mainImageUrl)) { image in
image
.resizable().scaledToFit().frame(height: 350)
} placeholder: {
ProgressView()
}.frame(height: 350).cornerRadius(10)
HStack {
ForEach(images, id: \.self) { imageUrl in
AsyncImage(url: URL(string: imageUrl)) { sourceImage in
sourceImage
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 100, height: 100, alignment: .center)
.clipped()
} placeholder: {
ProgressView()
}.onTapGesture {
self.mainImageUrl = imageUrl
}
}
}
}
}
}
It sort of works if you just flip the frame and aspectRatio as in the code below.
However it is very slow and you are constantly re-downloading the images whenever you click on a thumbnail.
The last image is specially slow.
struct ContentView: View {
var body: some View {
Thumbnail()
}
}
struct Thumbnail: View {
var images: [String] = [
"https://www.trinum.com/ibox/ftpcam/small_montgenevre_eglise.jpg",
"https://www.trinum.com/ibox/ftpcam/small_montgenevre_brousset.jpg",
"https://www.trinum.com/ibox/ftpcam/small_montgenevre_sommet-ts-crete.jpg",
"https://www.trinum.com/ibox/ftpcam/mega_mtgenevre_sommet-des-gondrans.jpg"
]
#State var mainImageUrl: String = "https://www.trinum.com/ibox/ftpcam/small_montgenevre_eglise.jpg"
var body: some View {
VStack {
AsyncImage(url: URL(string: mainImageUrl)) { image in
image.resizable().scaledToFit().frame(height: 350)
} placeholder: {
ProgressView()
}.frame(height: 350).cornerRadius(10)
HStack {
ForEach(images, id: \.self) { imageUrl in
AsyncImage(url: URL(string: imageUrl)) { sourceImage in
sourceImage
.resizable()
.frame(width: 100, height: 100) // <--- here
.aspectRatio(contentMode: .fill) // <--- here
.clipped()
} placeholder: {
ProgressView()
}.onTapGesture {
self.mainImageUrl = imageUrl
}
}
}
}
}
}
IMHO, a better way is to use a different approach to avoid the constant downloading of images.
You could download the pictures in parallel only once, using swift async/await concurrency.
Such as in this code:
struct Thumbnail: View {
#StateObject var loader = ImageLoader()
#State var selectedPhoto: PhotoImg?
var body: some View {
VStack {
if loader.images.count < 1 {
ProgressView()
} else {
Image(uiImage: selectedPhoto?.image ?? UIImage(systemName: "smiley")!)
.frame(height: 350).cornerRadius(10)
ScrollView {
HStack (spacing: 10) {
ForEach(loader.images) { photo in
Image(uiImage: photo.image)
.resizable()
.frame(width: 100, height: 100)
.aspectRatio(contentMode: .fill)
.onTapGesture {
selectedPhoto = photo
}
}
}
}
.onAppear {
if let first = loader.images.first {
selectedPhoto = first
}
}
}
}
.task {
await loader.loadParallel()
}
}
}
class ImageLoader: ObservableObject {
#Published var images: [PhotoImg] = []
let urls: [String] = [
"https://www.trinum.com/ibox/ftpcam/small_montgenevre_eglise.jpg",
"https://www.trinum.com/ibox/ftpcam/small_montgenevre_brousset.jpg",
"https://www.trinum.com/ibox/ftpcam/small_montgenevre_sommet-ts-crete.jpg",
"https://www.trinum.com/ibox/ftpcam/mega_mtgenevre_sommet-des-gondrans.jpg"
]
func loadParallel() async {
return await withTaskGroup(of: (String, UIImage).self) { group in
for str in urls {
if let url = URL(string: str) {
group.addTask { await (url.absoluteString, self.loadImage(url: url)) }
}
}
for await result in group {
DispatchQueue.main.async {
self.images.append(PhotoImg(url: result.0, image: result.1))
}
}
}
}
private func loadImage(url: URL) async -> UIImage {
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let img = UIImage(data: data) { return img }
}
catch { print(error) }
return UIImage()
}
}
struct PhotoImg: Identifiable, Hashable {
let id = UUID()
var url: String
var image: UIImage
}
This was very useful. Thanks. I simply had to lines flipped:
.scaledToFill()
.frame(width: 80, height: 80)
Yet it seemed those two lines in the order presented above caused this to work properly. Any idea why this is the case?

Passing multiple subviews to a view in SwiftUI

I have created a Swift Package that creates multiple PageTabViews from an array of content that is passed to it:
import SwiftUI
public struct WhatsNewView<Content: View>: View {
let content: [Content]
public init(content: [Content]){
self.content = content
}
public var body: some View {
TabView {
ForEach(0..<content.count, id: \.self) { pageNum in
WhatsNewPage(content: content[pageNum], pageNum: pageNum + 1, totalPages: content.count)
}
}
.background(Color.white)
.ignoresSafeArea()
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
}
}
When I call it I want to pass in any kind of simple or complex view to fill each page. Right now, I can pass in an array of simple text views like so:
import SwiftUI
import WhatsNew
#main
struct mFood_Vendor: App {
#State var showWhatsNew = false
let whatsNew = WhatsNew()
var page1 = Text("Hello World")
var page2 = Text("Goodbye World")
var body: some Scene {
WindowGroup {
ContentView()
.fullScreenCover(isPresented: $showWhatsNew, content: {
let content = [page1, page2]
WhatsNewView(content: content)
})
.onAppear(perform: {
whatsNew.checkForUpdate(showWhatsNew: $showWhatsNew)
})
}
}
}
I want page1 and page2 to be whatever content a person wants to see on the What's New pages. But if I change those vars to anything different, like a text and an Image, I get a "Failed to produce diagnostic for expression" error.
Ideally, I would like to be able to pass in something like:
struct page1: View {
var body: some View {
VStack {
Text("something")
Image("plus")
}
}
}
Any help would be appreciated. THANKS!
You can use AnyView to get what you want. In that case your code would become:
public struct WhatsNewView: View {
let content: [AnyView]
public init(content: [AnyView]){
self.content = content
}
public var body: some View {
TabView {
ForEach(0..<content.count, id: \.self) { pageNum in
WhatsNewPage(content: content[pageNum], pageNum: pageNum + 1, totalPages: content.count)
}
}
.background(Color.white)
.ignoresSafeArea()
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
}
}
And, as an example of usage:
struct ContentView: View {
var body: some View {
let view1 = AnyView(Text("Hello World"))
let view2 = AnyView(Image(systemName: "star.fill"))
return WhatsNewView(content: [view1, view2])
}
}
EDIT: I've just found out that TabView can be built out of a TupleView. This means that, depending on your needs, you can write something like this (which would be great because it doesn't force your Swift Package users to wrap all the views inside AnyView):
import SwiftUI
public struct WhatsNewView<Content: View>: View {
private let content: Content
public init(#ViewBuilder contentProvider: () -> Content){
content = contentProvider()
}
public var body: some View {
TabView {
content
}
.background(Color.white)
.ignoresSafeArea()
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
}
}
You can use it like this:
struct ContentView: View {
var body: some View {
WhatsNewView {
Text("Hello World")
Image(systemName: "star.fill")
Color.red
VStack {
Text("Text1")
Text("Text2")
Text("Text3")
}
Button("Tap Me") {
print("Tapped")
}
Group {
Text("Group1")
Text("Group2")
}
}
}
}
The result is:
Turns out that if I leave WhatsNewView alone, I can just do the following:
struct mFood_Vendor: App {
#State var showWhatsNew = false
let whatsNew = WhatsNew()
var page1: some View {
VStack (alignment: .leading){
Text("Here is a list of new features:")
Text("Hello World")
Image(systemName: "plus")
}
}
var page2: some View {
Text("Goodbye World")
}
var body: some Scene {
WindowGroup {
ContentView()
.fullScreenCover(isPresented: $showWhatsNew, content: {
let content = [AnyView(page1), AnyView(page2)]
WhatsNewView (content: content)
})
.onAppear(perform: {
whatsNew.checkForUpdate(showWhatsNew: $showWhatsNew)
})
}
}
}
Basically just wrap each content page in AnyView.

SwiftUI - GeometryReader crashes SIGABRT

I'm trying to implement two different views depending on the device width. So the iPad Version of this view should be different to the iPhone version. To do this, I use the GeometryReader to check for the width. However, the app always crashes with "Thread 1: signal SIGABRT".
Each of the views on their own work perfectly fine.
If I start it in Splitscreen for iPad with a width less than 592, it works fine. I can change it to the big size afterwards without a crash. If start with a width greater than 592, it crashes.
Also if I only use the if statement without the else, it works.
Even the test on top crashes.
Here my code:
import SwiftUI
struct DetailView: View {
let food: FoodList
#State var showRightMenu = false
var body: some View {
GeometryReader { bounds in
ZStack (alignment: .topLeading) {
// Test
if bounds.size.width > 592 {
Text("Test")
} else {
Text("Test1")
Text("Test2")
}
// Actual code
// if bounds.size.width > 592 {
// HStack {
// FoodDetailPadViewLeft(food: self.food)
// .frame(width: bounds.size.width / 2)
//
// FoodDetailPadViewRight(food: self.food)
// }
// } else {
// ScrollView {
// FoodDetailViewImage(food: self.food)
// .animation(.none)
//
// FoodDetailViewNutris(food: self.food)
//
// Spacer()
// }
// }
HStack {
BackButton()
Spacer()
InfoButton(showRightMenu: self.$showRightMenu)
}
}
.background(Color("background"))
.edgesIgnoringSafeArea(.all)
.navigationBarTitle("")
.navigationBarHidden(true)
.navigationBarBackButtonHidden(true)
}
}
}
Here is some reproducible code:
import SwiftUI
struct ContentView: View {
#State var foodlist: [FoodList] = Bundle.main.decode("ingredientsList.json")
var body: some View {
NavigationView {
List {
ForEach(foodlist) { food in
NavigationLink (destination: TestView(food: food)) {
Text(food.name)
}
}
}
}
}
}
struct TestView: View {
let food: FoodList
var body: some View {
GeometryReader { bounds in
ZStack (alignment: .topLeading) {
if bounds.size.width > 592 {
Text(self.food.name)
} else {
Text(self.food.name)
Text(self.food.category)
}
}
}
}
}
struct FoodList: Codable, Identifiable, Hashable {
let id: Int
let category: String
let name: String
}
extension Bundle {
func decode<T: Codable>(_ file: String) -> T {
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("Failed to locate \(file) in bundle.")
}
guard let data = try? Data (contentsOf: url) else {
fatalError("Failed to load \(file) from bundle.")
}
let decoder = JSONDecoder()
guard let loaded = try? decoder.decode(T.self, from: data) else {
fatalError("Failed to decode \(file) from bundle.")
}
return loaded
}
}
and the Json-File:
[
{
"id": 1,
"category": "vegetables",
"name": "Tomato",
},
{
"id": 2,
"category": "vegetables",
"name": "Potato",
}
]
Any ideas?
This is that case when it is better to check explicitly for what you need:
var body: some View {
ZStack (alignment: .topLeading) {
if UIDevice.current.userInterfaceIdiom == .pad {
Text(self.food.name)
} else {
Text(self.food.name)
Text(self.food.category)
}
}
}
Note: the crash happens due to changed layout on the stack, and that happens because GeometryReader on same layout stack got different values (in first turn it is .zero, and second it is real), so different branches of your condition are activated on same layout stack and this makes SwiftUI rendering engine crazy. (You can submit feedback for this to Apple, but it is hardly to be resolved, because it is chicken-egg problem - Geometry reader always go two times).
Not shure if this is the right solution, but I used the opacity to hide the views when the width is greater than 592.
Seems to work for the moment.
var body: some View {
GeometryReader { bounds in
ZStack (alignment: .topLeading) {
Text(self.food.name)
.opacity(bounds.size.width > 592 ? 0 : 1)
VStack {
Text(self.food.name)
Text(self.food.category)
}
.opacity(bounds.size.width <= 592 ? 0 : 1)
}
}
}

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