I'm trying to implement a custom view modifier to detect mouse up/down events within a SwiftUI view hierarchy. This is my solution so far:
extension View {
func onMouseDown(action: #escaping (Bool) -> Void) -> some View {
MouseDownView(action: action) { self }
}
}
struct MouseDownView<Content: View>: View {
let action: (Bool) -> Void
let content: () -> Content
init(action: #escaping (Bool) -> Void, #ViewBuilder content: #escaping () -> Content) {
self.action = action
self.content = content
}
var body: some View {
MouseDownRepresentable(action: action, content: content())
}
}
struct MouseDownRepresentable<Content: View>: NSViewRepresentable {
let action: (Bool) -> Void
let content: Content
func makeNSView(context: Context) -> NSHostingView<Content> {
MouseDownHostingView(action: action, rootView: content)
}
func updateNSView(_ view: NSHostingView<Content>, context: Context) {
}
}
class MouseDownHostingView<Content: View>: NSHostingView<Content> {
let action: (Bool) -> Void
init(action: #escaping (Bool) -> Void, rootView: Content) {
self.action = action
super.init(rootView: rootView)
}
required init(rootView: Content) {
fatalError()
}
required init?(coder: NSCoder) {
fatalError()
}
override func mouseDown(with event: NSEvent) {
action(true)
}
override func mouseUp(with event: NSEvent) {
action(false)
}
}
It works, in the sense that the closure passed to onMouseDown is getting called whenever a mouse up/down event occurs within the the view that it was applied to (even taking non-rectangular shapes into account). Unfortunately though, there is an issue that prevents the view from updating when any of its #State variables get modified inside the passed closure.
Example usage:
struct PlayerView<Content: View>: View {
private let content: () -> Content
#State var isPlaying = true
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
var body: some View {
ZStack {
content()
ZStack {
Circle()
.foregroundStyle(.secondary)
Image(systemName: isPlaying ? "play.fill" : "pause.fill")
.foregroundColor(.white)
.font(.system(size: 40))
}
.onMouseDown { isDown in
if isDown {
// ...
} else {
isPlaying.toggle()
}
}
.frame(width: 80, height: 80)
}
}
}
isPlaying gets toggled everytime the circular view is clicked, but the image never updates. How can this be? Does this configuration of an NSViewRepresentable inside of an NSHostingView somehow mean, that the modified view is no longer formally part of the original view hierarchy, and therefore not allowed to update it?
I tried wrapping isPlaying as a published property inside of an ObservableObject that gets attached to the view as a #StateObject, but it showed the same behavior.
Does anyone know what's going on here and/or how to work around it?
Yes, SwiftUI does not see dependency through NSView bridge anymore, so instead the content should remain in SwiftUI world, but modifier/handler be placed above it, like
extension View {
func onMouseDown(action: #escaping (Bool) -> Void) -> some View {
self.overlay(
MouseDownView(action: action) {
Color.clear.contentShape(Rectangle())
})
}
}
Tested with Xcode 13.4 / macOS 12.5
Related
I have been trying to display a custom NSMenuItem (for a preview page of a menu manager) inside a SwiftUI view. But I can't achieve it. I have figured it needs to wrapped inside a menu first, and thought that there might be a way to pop the menu pragmatically but sadly, those efforts have failed and the app crashes.
So far, my code looks like this:
import Foundation
import SwiftUI
struct NSMenuItemView: NSViewRepresentable {
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
func makeNSView(context: Context) -> NSView {
let view = NSView()
let menu = NSMenu()
let item = menu.addItem(withTitle: "Do Action", action: #selector(Coordinator.valueChanged(_:)), keyEquivalent: "")
item.target = context.coordinator
view.menu = menu
return view
}
func updateNSView(_ view: NSView, context: Context) {
// App crashes here :/
view.menu?.popUpMenuPositioningItem(
positioning: view.menu?.item(at: 0),
at: NSPoint(x: 0, y: 0),
in: view
)
}
}
extension NSMenuItemView {
final class Coordinator: NSObject {
var parent: NSMenuItemView
init(_ parent: NSMenuItemView) {
self.parent = parent
}
#objc
func valueChanged(_ sender: NSMenuItem) {
}
}
}
Am I missing something here? Is it even possible to just pragmatically display NSMenuItem?
The NSMenu comfors to NSViewRepresentable so I figured it might just workout, and have seen answers on StackOverflow (granted date a while back) showing similar code that should work.
Without the popUpMenuPositioningItem it works - in a way I guess - when I right click in the View, the MenuItem Appears. But I would like to be able to display the menu without the right click, just like that.
The problem is that the menu is shown while the view are still rendering so that the crash happens. To avoid this you should call popUp(positioning:at:in) after the your view appears on the screen. The way to achieve it, we have to use publisher to trigger an event to show menu inside onAppear modifier and listen it inside Coordinator. Here is the sample for that solution.
struct ContentView: View {
let menuPopUpTrigger = PassthroughSubject<Void, Never>()
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
NSMenuItemView(menuPopUpTrigger)
Text("Hello, world!")
}
.padding()
.onAppear {
/// trigger an event when `onAppear` is executed
menuPopUpTrigger.send()
}
}
}
struct NSMenuItemView: NSViewRepresentable {
let base = NSView()
let menu = NSMenu()
var menuPopUpTrigger: PassthroughSubject<Void, Never>
init(_ menuPopUpTrigger: PassthroughSubject<Void, Never>) {
self.menuPopUpTrigger = menuPopUpTrigger
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
func makeNSView(context: Context) -> NSView {
let item = menu.addItem(withTitle: "Do Action", action: #selector(Coordinator.valueChanged(_:)), keyEquivalent: "")
item.target = context.coordinator
base.menu = menu
context.coordinator.bindTrigger(menuPopUpTrigger)
return base
}
func updateNSView(_ view: NSView, context: Context) { }
}
extension NSMenuItemView {
final class Coordinator: NSObject {
var parent: NSMenuItemView
var cancellable: AnyCancellable?
init(_ parent: NSMenuItemView) {
self.parent = parent
}
#objc func valueChanged(_ sender: NSMenuItem) { }
/// bind trigger to listen an event
func bindTrigger(_ trigger: PassthroughSubject<Void, Never>) {
cancellable = trigger
.delay(for: .seconds(0.1), scheduler: RunLoop.main)
.sink { [weak self] in
self?.parent.menu.popUp(
positioning: self?.parent.menu.item(at: 0),
at: NSPoint(x: 0, y: 0),
in: self?.parent.base
)
}
}
}
}
I hope it will help you to get what you want.
This will show nothing (blank screen):
import SwiftUI
import UIKit
struct ContentView: View {
#State var navigate = false
var body: some View {
NavigationView {
NavigationLink(destination: Text("clicked"), isActive: $navigate) {
EmptyView()
}
UIKitView {
navigate = true
}
.navigationTitle(Text("test"))
}
.navigationViewStyle(.stack)
}
}
struct UIKitView: UIViewRepresentable {
let didClick: () -> Void
func makeUIView(context _: Context) -> UIView {
MyView(didClick: didClick)
}
func updateUIView(_: UIView, context _: Context) {}
}
class MyView: UIView {
let didClick: () -> Void
init(didClick: #escaping () -> Void) {
self.didClick = didClick
super.init(frame: .zero)
backgroundColor = .red
let tap = UITapGestureRecognizer(target: self, action: #selector(viewTapped))
addGestureRecognizer(tap)
}
#available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
#objc func viewTapped(recognizer _: UIGestureRecognizer) {
didClick()
}
}
I am wondering where my UIKitView is. Without the NavigationLink, it is showing but than I can not navigate.
When I change the order (first NavigationLink than UIKitView), it won't navigate anymore (and the navigationTitle is gone).
How can I make this extremely simple example work without using a ZStack? This is because I use the ZStack hack (just wrap the views inside a ZStack) in my 'real' application, but that gives other problems down the line.
I want to make popover on iPhone , I notice when using .popover in iPhone it will always show as sheet , but on iPad it will show as popover
so I decide to use UIKit version
everything is working fine until I tap on Button to update the view
it will crash with this error
Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Application tried to present modally a view controller <_TtGC7SwiftUI19UIHostingControllerGVS_6HStackGVS_9TupleViewTGVS_6ButtonVS_4Text_S4_GS3_S4______: 0x7fd47b424df0> that is already being presented by <UIViewController: 0x7fd47b426200>.
My code :
struct PopoverViewModifier<PopoverContent>: ViewModifier where PopoverContent: View {
#Binding var isPresented: Bool
let onDismiss: (() -> Void)?
let content: () -> PopoverContent
func body(content: Content) -> some View {
content
.background(
Popover(
isPresented: self.$isPresented,
onDismiss: self.onDismiss,
content: self.content
)
)
}
}
extension View {
func popover<Content>(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
content: #escaping () -> Content
) -> some View where Content: View {
ModifiedContent(
content: self,
modifier: PopoverViewModifier(
isPresented: isPresented,
onDismiss: onDismiss,
content: content
)
)
}
}
struct Popover<Content: View> : UIViewControllerRepresentable {
#Binding var isPresented: Bool
let onDismiss: (() -> Void)?
#ViewBuilder let content: () -> Content
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self, content: self.content())
}
func makeUIViewController(context: Context) -> UIViewController {
return UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
let host = context.coordinator.host
if self.isPresented {
host.preferredContentSize = host.sizeThatFits(in: CGSize(width: Int.max , height: Int.max))
host.modalPresentationStyle = UIModalPresentationStyle.popover
host.popoverPresentationController?.delegate = context.coordinator
host.popoverPresentationController?.sourceView = uiViewController.view
host.popoverPresentationController?.sourceRect = uiViewController.view.bounds
uiViewController.present(host, animated: true, completion: nil)
}
else {
host.dismiss(animated: true, completion: nil)
}
}
class Coordinator: NSObject, UIPopoverPresentationControllerDelegate {
let host: UIHostingController<Content>
private let parent: Popover
init(parent: Popover, content: Content) {
self.parent = parent
self.host = UIHostingController(rootView: content)
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
self.parent.isPresented = false
if let onDismiss = self.parent.onDismiss {
onDismiss()
}
}
func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
return .none
}
}
}
How I use it :
struct ContentView: View {
#State var openChangeFont = false
#State var currentFontSize = 0
var body: some View {
NavigationView {
Text("Test")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
Text("Popover")
.popover(isPresented: $openChangeFont, content: {
HStack {
Button(action: {
DispatchQueue.main.async {
currentFontSize += 2
}
}, label: {
Text("Increase")
})
Text("\(currentFontSize)")
Button(action: {
currentFontSize -= 2
}, label: {
Text("Decrease")
})
}
})
.onTapGesture {
openChangeFont.toggle()
}
}
}
}
}
Put a breakpoint in Popover.updateUIViewController and I think you’ll catch the problem. updateUIViewController is called every time the SwiftUI view is updated, which means it may be called when isPresented is true and you the popover is already being presented.
If that’s the issue, then you need to track whether you’re already presenting the popover or not. You’re already implementing UIPopoverPresentationControllerDelegate so you can use that.
I have an app that uses a UIViewRepresentable and view introspection to create pull to refresh functionality for ScrollViews in my SwiftUI app and it worked in iOS 13. However, iOS 14 has broken it and I don't know how to fix it. Here is the implementation:
import SwiftUI
import Introspect
private struct PullToRefresh: UIViewRepresentable {
#Binding var isShowing: Bool
let onRefresh: () -> Void
public init(
isShowing: Binding<Bool>,
onRefresh: #escaping () -> Void
) {
_isShowing = isShowing
self.onRefresh = onRefresh
}
public class Coordinator {
let onRefresh: () -> Void
let isShowing: Binding<Bool>
init(
onRefresh: #escaping () -> Void,
isShowing: Binding<Bool>
) {
self.onRefresh = onRefresh
self.isShowing = isShowing
}
#objc
func onValueChanged() {
isShowing.wrappedValue = true
onRefresh()
}
}
public func makeUIView(context: UIViewRepresentableContext<PullToRefresh>) -> UIView {
let view = UIView(frame: .zero)
view.isHidden = true
view.isUserInteractionEnabled = false
return view
}
private func tableView(entry: UIView) -> UIScrollView? {
// Search in ancestors - FAILS HERE
if let tableView = Introspect.findAncestor(ofType: UIScrollView.self, from: entry) {
return tableView
}
guard let viewHost = Introspect.findViewHost(from: entry) else {
return nil
}
// Search in siblings - FAILS HERE
return Introspect.previousSibling(containing: UIScrollView.self, from: viewHost)
}
public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<PullToRefresh>) {
DispatchQueue.main.asyncAfter(deadline: .now()) {
guard let tableView = self.tableView(entry: uiView) else {
return
}
if let refreshControl = tableView.refreshControl {
if self.isShowing {
refreshControl.beginRefreshing()
} else {
refreshControl.endRefreshing()
}
return
}
let refreshControl = UIRefreshControl()
refreshControl.addTarget(context.coordinator, action: #selector(Coordinator.onValueChanged), for: .valueChanged)
tableView.refreshControl = refreshControl
}
}
public func makeCoordinator() -> Coordinator {
return Coordinator(onRefresh: onRefresh, isShowing: $isShowing)
}
}
extension View {
public func pullToRefresh(isShowing: Binding<Bool>, onRefresh: #escaping () -> Void) -> some View {
return overlay(
PullToRefresh(isShowing: isShowing, onRefresh: onRefresh)
.frame(width: 0, height: 0)
, alignment: .center)
}
}
This code is slightly modified from this repository.
Here is a rough version of the usage:
NavigationView {
VStack(alignment: .center) {
...
GeometryReader { geometry in
ScrollView(.vertical) {
...
}
.pullToRefresh(isShowing: self.$refreshingEvents, onRefresh: refreshEvents)
}
}.navigationBarTitle("Events", displayMode: .inline)
}
When I use this, the ScrollView doesn't have a refresh indicator attached. Moving the pullToRefresh to a child view of the ScrollView doesn't change anything. Here is what is returned from introspecting:
PlatformViewHost<PlatformViewRepresentableAdaptor<PullToRefresh>>
_UIHostingView<ModifiedContent<Element, StyleContextWriter<SidebarStyleContext>>>
UIViewControllerWrapperView
UINavigationTransitionView
UILayoutContainerView
_UIPanelControllerContentView
_UISplitViewControllerPanelImplView
PlatformViewHost<PlatformViewControllerRepresentableAdaptor<MulticolumnSplitViewRepresentable<Element, Never, _UnaryViewAdaptor<EmptyView>>>>
_UIHostingView<_ViewList_View>
UIViewControllerWrapperView
UITransitionView
UILayoutContainerView
PlatformViewHost<PlatformViewControllerRepresentableAdaptor<UIKitTabView>>
_UIHostingView<ModifiedContent<MainView, _EnvironmentKeyWritingModifier<Optional<UserData>>>>
UIDropShadowView
UITransitionView
UIWindow
There should be a UIScrollView somewhere in there, but there isn't on iOS 14. Is there a way I could fix my current code or update it with an alternative that would retain the same functionality?
I've got the following code, which makes it possible to use the UIKit's UIScrollView in my SwiftUI code. It can be pasted in a new SwiftUI project.
struct LegacyScrollView<Content: View>: UIViewRepresentable {
enum Action {
case idle
case offset(x: CGFloat, y: CGFloat, animated: Bool)
}
#Binding var action: Action
let content: Content
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIScrollView {
let hosting = UIHostingController(rootView: self.content)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
let uiScrollView = UIScrollView()
uiScrollView.addSubview(hosting.view)
let constraints = [
hosting.view.leadingAnchor.constraint(equalTo: uiScrollView.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: uiScrollView.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.topAnchor),
hosting.view.bottomAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.bottomAnchor),
hosting.view.widthAnchor.constraint(equalTo: uiScrollView.widthAnchor)
]
uiScrollView.addConstraints(constraints)
return uiScrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
switch self.action {
case .offset(let x, let y, let animated):
uiView.setContentOffset(CGPoint(x: x, y: y), animated: animated)
DispatchQueue.main.async {
self.action = .idle
}
default:
break
}
}
class Coordinator: NSObject {
let legacyScrollView: LegacyScrollView
init(_ legacyScrollView: LegacyScrollView) {
self.legacyScrollView = legacyScrollView
}
}
init(#ViewBuilder content: () -> Content) {
self._action = Binding.constant(Action.idle)
self.content = content()
}
init(action: Binding<Action>, #ViewBuilder content: () -> Content) {
self._action = action
self.content = content()
}
}
struct ContentView: View {
#State private var action = LegacyScrollView.Action.idle
var body: some View {
VStack(spacing: 0) {
LegacyScrollView(action: self.$action) {
ForEach(0 ..< 40) { _ in
Text("Hello, World!")
}
}
.padding(20)
.background(Color.gray)
Spacer()
Button("Set offset") {
self.action = LegacyScrollView.Action.offset(x: 0, y: 200, animated: true)
}.padding()
}
}
}
The code above will give Generic parameter 'Content' could not be inferred on the first line of the ContentView. I've tried to change the line to:
#State private var action = LegacyScrollView<AnyView>.Action.idle
but that will give another error. It works when I place the enum Action outside the struct LegacyScrollView. But in my opinion, that's a rather inelegant scoping of this enum. How can I solve the error message?
Here is possible approach that allows usage of provided ContentView as-is.
Just change the direction of... instead of making entire type generic, which is actually not needed in this case, just make a generic only initialisation, like below.
Also it actually makes clear that Action is Content-independent, that is really correct.
Tested & works with Xcode 11.2 / iOS 13.2 (w/o no changes in ContentView)
struct LegacyScrollView: UIViewRepresentable {
enum Action {
case idle
case offset(x: CGFloat, y: CGFloat, animated: Bool)
}
#Binding var action: Action
private let uiScrollView: UIScrollView
init<Content: View>(content: Content) {
let hosting = UIHostingController(rootView: content)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
uiScrollView = UIScrollView()
uiScrollView.addSubview(hosting.view)
let constraints = [
hosting.view.leadingAnchor.constraint(equalTo: uiScrollView.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: uiScrollView.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.topAnchor),
hosting.view.bottomAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.bottomAnchor),
hosting.view.widthAnchor.constraint(equalTo: uiScrollView.widthAnchor)
]
uiScrollView.addConstraints(constraints)
self._action = Binding.constant(Action.idle)
}
init<Content: View>(#ViewBuilder content: () -> Content) {
self.init(content: content())
}
init<Content: View>(action: Binding<Action>, #ViewBuilder content: () -> Content) {
self.init(content: content())
self._action = action
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIScrollView {
return uiScrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
switch self.action {
case .offset(let x, let y, let animated):
uiView.setContentOffset(CGPoint(x: x, y: y), animated: animated)
DispatchQueue.main.async {
self.action = .idle
}
default:
break
}
}
class Coordinator: NSObject {
let legacyScrollView: LegacyScrollView
init(_ legacyScrollView: LegacyScrollView) {
self.legacyScrollView = legacyScrollView
}
}
}
I disagree with your assertion that the enum should be nested inside the class for the following reasons:
The enum is intended to be used both inside and outside of the class, with a generic type being required in order to use it.
The enum does not make use of, and therefore has no dependency on, the generic Content type.
With a good enough name, the intended use of the enum would be obvious.
If you really want to nest the enum definition, I would suggest the following:
Drop the generic type requirement on the class definition,
Convert your content member to be of AnyView type,
Make your init functions generic and store the return values of the given view builders into type-erased views, like so:
init<Content: View>(#ViewBuilder content: () -> Content) {
self._action = Binding.constant(Action.idle)
self.content = AnyView(content())
}
init<Content: View>(action: Binding<Action>, #ViewBuilder content: () -> Content) {
self._action = action
self.content = AnyView(content())
}
Of course, with this approach, you will:
Lose the type information of the underlying content view.
Possibly incur a greater runtime cost with type-erased views.
So it depends what you value more in this case... Ahhh, tradeoffs...