How to add a custom tap handler on a custom SwiftUI View? - swiftui

List {
ItemView(item: item)
.myCustomTapHandler {
print("ItemView was tapped, triggered from List!")
}
}
}
struct ItemView: View {
var body: some View {
VStack {
Button(action: {
// this should fire myCustomTapHandler
}) {
Text("Hello world")
}
}
}
I have a custom ItemView with a simple button. I want to re create the same trailing closure syntax as .onTapGesture, with only triggering when you tap the Button. This will be named .myCustomTapHandler How to do this in SwiftUI?

What you are describing (The dot) is a ViewModifier
struct MyItemListView: View {
var body: some View {
List {
//Using a ViewModifier you make the whole View tappable not just the button
MyItemView(item: 2)
.myCustomTapHandler{
print("ItemView has custom modifier that makes the whole ItemView tappable")
}
}
}
}
struct MyCustomTapHandler: ViewModifier {
var myCustomTapHandler: () -> Void
func body(content: Content) -> some View {
content
//Add the onTap to the whole View
.onTapGesture {
myCustomTapHandler()
}
}
}
extension View {
func myCustomTapHandler(myCustomTapHandler: #escaping () -> Void) -> some View {
modifier(MyCustomTapHandler(myCustomTapHandler: myCustomTapHandler))
}
}
The ViewModifier affects the entire View not just the Button.
But, unless you are doing something else it is just an onTapGesture.
This is likely not the best solution because with the Button in the ItemView you will have inconsistent results.
Sometimes the Button will get the tap and sometimes the ViewModifier will get the tap and given that the View is in a List it will likely make the whole tapping confusing because the List has properties that make the whole row tappable anyway vs just the Text of the `Button
If you want the Button to perform an action that is defined in the ListView you can pass it as a parameter.
This will likely give you the best results.
struct MyItemListView: View {
var body: some View {
VStack {
//This passes the custom action to the button
MyItemView(item: 1){
print("Button needs to be tapped to trigger this")
}
}
}
}
struct MyItemView: View {
let item: Int
var myCustomTapHandler: () -> Void
var body: some View {
VStack {
Button(action: {
myCustomTapHandler()
}) {
Text("Hello world")
}
}
}
}

You can add like this.
struct ItemView: View {
private var action: (() -> Void)? = nil
var body: some View {
VStack {
Button(action: {
action?()
}) {
Text("Hello world")
}
}
}
func myCustomTapHandler(onAction: #escaping () -> Void) -> Self {
var view = self
view.action = onAction
return view
}
}
Usage
struct ContentView: View {
var body: some View {
ItemView()
.myCustomTapHandler {
print("Hello word")
}
}
}
Another way is...
I don't think with dot property you will get action. You need closure inside the ItemView.
Like this
struct ItemView: View {
var action: () -> Void
var body: some View {
VStack {
Button(action: action) {
Text("Hello world")
}
}
}
}
Usage
List {
ItemView {
// Here you will get action
print("ItemView was tapped, triggered from List!")
}
}

Related

In SwiftUI, how to implement something like `navigationBarItems(...)`

I've had some problems with SwiftUI's navigation API, so I'm experimenting with implementing my own. Parts of this are relatively easy: I create a class NavModel that is basically a stack. Depending on what's on the top of that stack, I can display different views.
But I can't see how to implement something like SwiftUI's .navigationBarItems(...). That view modifier seems to use something like the Preferences API to pass its argument View up the hierarchy to the containing navigation system. Eg:
VStack {
...
}.navigationBarItems(trailing: Button("Edit") { startEdit() })
Anything that goes through onPreferenceChange(...) has to be Equatable, so if I want to pass an AnyView? for the navigation bar items, I need to somehow may it Equatable, and I don't see how to do that.
Here's some sample code that shows a basic push and pop navigation. I'm wondering: how could I make the navBarItems(...) work? (The UI is ugly, but that's not important now.)
struct ContentView: View {
#StateObject var navModel: NavModel = .shared
var body: some View {
NavView(model: navModel) { node in
switch node {
case .root: rootView
case .foo: fooView
}
}
}
var rootView: some View {
VStack {
Text("This is the root")
Button {
navModel.push(.foo)
} label: {
Text("Push a view")
}
}
}
var fooView: some View {
VStack {
Text("Foo")
Button {
navModel.pop()
} label: {
Text("Pop nav stack")
}
}.navBarItems(trailing: Text("Test"))
}
}
struct NavView<Content: View>: View {
#ObservedObject var model: NavModel
let makeViews: (NavNode) -> Content
init(model: NavModel, #ViewBuilder makeViews: #escaping (NavNode) -> Content) {
self.model = model
self.makeViews = makeViews
}
#State var navItems: AnyView? = nil
var body: some View {
VStack {
let node = model.stack.last!
navBar
Divider()
makeViews(node)
.frame(maxHeight: .infinity)
// This doesn't compile
.onPreferenceChange(NavBarItemsPrefKey.self) { v in
navItems = v
}
}
}
var navBar: some View {
HStack {
if model.stack.count > 1 {
Button {
model.pop()
} label: { Text("Back") }
}
Spacer()
if let navItems = self.navItems {
navItems
}
}
}
}
enum NavNode {
case root
case foo
}
class NavModel: ObservableObject {
static let shared = NavModel()
#Published var stack: [NavNode]
init() {
stack = [.root]
}
func push(_ node: NavNode) { stack.append(node) }
func pop() {
if stack.count > 1 {
stack.removeLast()
}
}
}
struct NavBarItemsPrefKey: PreferenceKey {
typealias Value = AnyView?
static var defaultValue: Value = nil
static func reduce(value: inout Value, nextValue: () -> Value) {
let n = nextValue()
if n != nil { // ???
value = n
}
}
}
// Is this the right way? But then anything passed to navBarItems(...) would need
// to be Equatable. The common case - Buttons - are not.
struct AnyEquatableView: Equatable {
???
init<T>(_ ev: EquatableView<T>) {
???
}
static func == (lhs: AnyEquatableView, rhs: AnyEquatableView) -> Bool {
???
}
}
struct NavBarItemsModifier<T>: ViewModifier where T: View {
let trailing: T
func body(content: Content) -> some View {
content.preference(key: NavBarItemsPrefKey.self, value: AnyView(trailing))
}
}
extension View {
func navBarItems<T>(trailing: T) -> some View where T: View {
return self.modifier(NavBarItemsModifier(trailing: trailing))
}
}

Show a placeholder when a SwiftUI List is empty [duplicate]

I was wondering how to provide an empty state view in a list when the data source of the list is empty. Below is an example, where I have to wrap it in an if/else statement. Is there a better alternative for this, or is there a way to create a modifier on a List that'll make this possible i.e. List.emptyView(Text("No data available...")).
import SwiftUI
struct EmptyListExample: View {
var objects: [Int]
var body: some View {
VStack {
if objects.isEmpty {
Text("Oops, loos like there's no data...")
} else {
List(objects, id: \.self) { obj in
Text("\(obj)")
}
}
}
}
}
struct EmptyListExample_Previews: PreviewProvider {
static var previews: some View {
EmptyListExample(objects: [])
}
}
I quite like to use an overlay attached to the List for this because it's quite a simple, flexible modifier:
struct EmptyListExample: View {
var objects: [Int]
var body: some View {
VStack {
List(objects, id: \.self) { obj in
Text("\(obj)")
}
.overlay(Group {
if objects.isEmpty {
Text("Oops, loos like there's no data...")
}
})
}
}
}
It has the advantage of being nicely centred & if you use larger placeholders with an image, etc. they will fill the same area as the list.
One of the solutions is to use a #ViewBuilder:
struct EmptyListExample: View {
var objects: [Int]
var body: some View {
listView
}
#ViewBuilder
var listView: some View {
if objects.isEmpty {
emptyListView
} else {
objectsListView
}
}
var emptyListView: some View {
Text("Oops, loos like there's no data...")
}
var objectsListView: some View {
List(objects, id: \.self) { obj in
Text("\(obj)")
}
}
}
You can create a custom modifier that substitutes a placeholder view when your list is empty. Use it like this:
List(items) { item in
Text(item.name)
}
.emptyPlaceholder(items) {
Image(systemName: "nosign")
}
This is the modifier:
struct EmptyPlaceholderModifier<Items: Collection>: ViewModifier {
let items: Items
let placeholder: AnyView
#ViewBuilder func body(content: Content) -> some View {
if !items.isEmpty {
content
} else {
placeholder
}
}
}
extension View {
func emptyPlaceholder<Items: Collection, PlaceholderView: View>(_ items: Items, _ placeholder: #escaping () -> PlaceholderView) -> some View {
modifier(EmptyPlaceholderModifier(items: items, placeholder: AnyView(placeholder())))
}
}
I tried #pawello2222's approach, but the view didn't get rerendered if the passed objects' content change from empty(0) to not empty(>0), or vice versa, but it worked if the objects' content was always not empty.
Below is my approach to work all the time:
struct SampleList: View {
var objects: [IdentifiableObject]
var body: some View {
ZStack {
Empty() // Show when empty
List {
ForEach(objects) { object in
// Do something about object
}
}
.opacity(objects.isEmpty ? 0.0 : 1.0)
}
}
}
You can make ViewModifier like this for showing the empty view. Also, use View extension for easy use.
Here is the demo code,
//MARK: View Modifier
struct EmptyDataView: ViewModifier {
let condition: Bool
let message: String
func body(content: Content) -> some View {
valideView(content: content)
}
#ViewBuilder
private func valideView(content: Content) -> some View {
if condition {
VStack{
Spacer()
Text(message)
.font(.title)
.foregroundColor(Color.gray)
.multilineTextAlignment(.center)
Spacer()
}
} else {
content
}
}
}
//MARK: View Extension
extension View {
func onEmpty(for condition: Bool, with message: String) -> some View {
self.modifier(EmptyDataView(condition: condition, message: message))
}
}
Example (How to use)
struct EmptyListExample: View {
#State var objects: [Int] = []
var body: some View {
NavigationView {
List(objects, id: \.self) { obj in
Text("\(obj)")
}
.onEmpty(for: objects.isEmpty, with: "Oops, loos like there's no data...") //<--- Here
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button("Add") {
objects = [1,2,3,4,5,6,7,8,9,10]
}
Button("Empty") {
objects = []
}
}
}
}
}
}
In 2021 Apple did not provide a List placeholder out of the box.
In my opinion, one of the best way to make a placeholder, it's creating a custom ViewModifier.
struct EmptyDataModifier<Placeholder: View>: ViewModifier {
let items: [Any]
let placeholder: Placeholder
#ViewBuilder
func body(content: Content) -> some View {
if !items.isEmpty {
content
} else {
placeholder
}
}
}
struct ContentView: View {
#State var countries: [String] = [] // Data source
var body: some View {
List(countries) { country in
Text(country)
.font(.title)
}
.modifier(EmptyDataModifier(
items: countries,
placeholder: Text("No Countries").font(.title)) // Placeholder. Can set Any SwiftUI View
)
}
}
Also via extension can little bit improve the solution:
extension List {
func emptyListPlaceholder(_ items: [Any], _ placeholder: AnyView) -> some View {
modifier(EmptyDataModifier(items: items, placeholder: placeholder))
}
}
struct ContentView: View {
#State var countries: [String] = [] // Data source
var body: some View {
List(countries) { country in
Text(country)
.font(.title)
}
.emptyListPlaceholder(
countries,
AnyView(ListPlaceholderView()) // Placeholder
)
}
}
If you are interested in other ways you can read the article

LazyVStack not lazy if not within a ScrollView or List?

It seems that LazyVStack is only "lazy" when within a ScrollView or List?
Code below:
struct ContentView: View {
var body: some View {
makeBody()
}
private func makeBody() -> some View {
// ScrollView { // uncomment to see difference
ContainerView { //
LazyVStack {
ForEach(1...100, id: \.self) {
WrappedText(s: String($0))
}
}
}
}
struct WrappedText: View {
var s: String
var body: some View {
makeBody()
}
private func makeBody() -> some View {
print("Wrapped text body: \(s)") // this is called for all texts during initialisation (if not in a ScrollView / List)
return Text(String(s))
}
}
}
struct ContainerView<Content: View>: View {
let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
return self.content
}
}
I'm currently experimenting with creating a pure SwiftUI ScrollView, and am stuck on this.
I've tried using .clipped() and setting the .frame on ContainerView and LazyVStack. Any suggestions?

SwiftUI NavigationLink double click on List MacOS

Can anyone think how to call an action when double clicking a NavigationLink in a List in MacOS? I've tried adding onTapGesture(count:2) but it does not have the desired effect and overrides the ability of the link to be selected reliably.
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: Item(itemDetail: item)) {
ItemRow(itemRow: item) //<-my row view
}.buttonStyle(PlainButtonStyle())
.simultaneousGesture(TapGesture(count:2)
.onEnded {
print("double tap")
})
}
}
}
}
EDIT:
I've set up a tag/selection in the NavigationLink and can now double or single click the content of the row. The only trouble is, although the itemDetail view is shown, the "active" state with the accent does not appear on the link. Is there a way to either set the active state (highlighted state) or extend the NavigationLink functionality to accept double tap as well as a single?
#State var selection:String?
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: Item(itemDetail: item), tag: item.id, selection: self.$selection) {
ItemRow(itemRow: item) //<-my row view
}.onTapGesture(count:2) { //<- Needed to be first!
print("doubletap")
}.onTapGesture(count:1) {
self.selection = item.id
}
}
}
}
}
Here's another solution that seems to work the best for me. It's a modifier that adds an NSView which does the actual handling. Works in List even with selection:
extension View {
/// Adds a double click handler this view (macOS only)
///
/// Example
/// ```
/// Text("Hello")
/// .onDoubleClick { print("Double click detected") }
/// ```
/// - Parameters:
/// - handler: Block invoked when a double click is detected
func onDoubleClick(handler: #escaping () -> Void) -> some View {
modifier(DoubleClickHandler(handler: handler))
}
}
struct DoubleClickHandler: ViewModifier {
let handler: () -> Void
func body(content: Content) -> some View {
content.background {
DoubleClickListeningViewRepresentable(handler: handler)
}
}
}
struct DoubleClickListeningViewRepresentable: NSViewRepresentable {
let handler: () -> Void
func makeNSView(context: Context) -> DoubleClickListeningView {
DoubleClickListeningView(handler: handler)
}
func updateNSView(_ nsView: DoubleClickListeningView, context: Context) {}
}
class DoubleClickListeningView: NSView {
let handler: () -> Void
init(handler: #escaping () -> Void) {
self.handler = handler
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
if event.clickCount == 2 {
handler()
}
}
}
https://gist.github.com/joelekstrom/91dad79ebdba409556dce663d28e8297
I've tried all these solutions but the main issue is using gesture or simultaneousGesture overrides the default single tap gesture on the List view which selects the item in the list. As such, here's a simple method I thought of to retain the default single tap gesture (select row) and handle a double tap separately.
struct ContentView: View {
#State private var monitor: Any? = nil
#State private var hovering = false
#State private var selection = Set<String>()
let fruits = ["apple", "banana", "plum", "grape"]
var body: some View {
List(fruits, id: \.self, selection: $selection) { fruit in
VStack {
Text(fruit)
.frame(maxWidth: .infinity, alignment: .leading)
.clipShape(Rectangle()) // Allows the hitbox to be the entire word not the if you perfectly press the text
}
.onHover {
hovering = $0
}
}
.onAppear {
monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) {
if $0.clickCount == 2 && hovering { // Checks if mouse is actually hovering over the button or element
print("Double Tap!") // Run action
}
return $0
}
}
.onDisappear {
if let monitor = monitor {
NSEvent.removeMonitor(monitor)
}
}
}
}
This works if you just need to single tap to select and item, but only do something if the user double taps. If you want to handle a single tap and a double tap, there still remains the problem of single tap running when its a double tap. A potential work around would be to capture and delay the single tap action by a few hundred ms and cancel it if it was a double tap action
Use simultaneous gesture, like below (tested with Xcode 11.4 / macOS 10.15.5)
NavigationLink(destination: Text("View One")) {
Text("ONE")
}
.buttonStyle(PlainButtonStyle()) // << required !!
.simultaneousGesture(TapGesture(count: 2)
.onEnded { print(">> double tap")})
or .highPriorityGesture(... if you need double-tap has higher priority
Looking for a similar solution I tried #asperi answer, but had the same issue with tappable areas as the original poster. After trying many variations the following is working for me:
#State var selection: String?
...
NavigationLink(destination: HistoryListView(branch: string), tag: string, selection: self.$selection) {
Text(string)
.gesture(
TapGesture(count:1)
.onEnded({
print("Tap Single")
selection = string
})
)
.highPriorityGesture(
TapGesture(count:2)
.onEnded({
print("Tap Double")
})
)
}

Call parent's function from child of a child in SwiftUI

I have ParentView that passes a function to a ChildView, then the function is called in the ParentView when a button is clicked in the ChildView.
But what if I would want a Child of the Child to call the function? Would I need to pass the function further down, or is there a way to make a function somehow accessible throughout the environment?
struct ParentView: View {
func parentFunction() {
print("parentFunction called")
}
var body: some View {
ChildView(p: parentFunction)
}
}
struct ChildView: View {
var p: () -> ()
var body: some View {
VStack {
Text("child view")
Button(action: {
self.p()
}) {
Image(systemName: "person")
}
}
}
}
Yes it is possible to do using custom defined EnvironmentKey and then use it set parent view environment function, which will be available for all subviews.
Here is a demo of approach
struct ParentFunctionKey: EnvironmentKey {
static let defaultValue: (() -> Void)? = nil
}
extension EnvironmentValues {
var parentFunction: (() -> Void)? {
get { self[ParentFunctionKey.self] }
set { self[ParentFunctionKey.self] = newValue }
}
}
struct ParentView: View {
func parentFunction() {
print("parentFunction called")
}
var body: some View {
VStack {
ChildView()
}
.environment(\.parentFunction, parentFunction) // set in parent
}
}
struct ChildView: View {
#Environment(\.parentFunction) var parentFunction // join in child
var body: some View {
VStack {
Text("child view")
Button(action: {
self.parentFunction?() // < use in child
}) {
Image(systemName: "person")
}
Divider()
SubChildView()
}
}
}
struct SubChildView: View {
#Environment(\.parentFunction) var parentFunction // join in next subchild
var body: some View {
VStack {
Text("Subchild view")
Button(action: {
self.parentFunction?() // use in next subchild
}) {
Image(systemName: "person.2")
}
}
}
}