SwiftUI Custom View For Context Menu - ios

I want to implement same custom popup view when press long press gesture on a view as shown in the photo (from Tweeter App), so I can show a custom view and context menu at same time.

You need to make a custom ContextMenu using UIContextMenu from UIKit.
struct ContextMenuHelper<Content: View, Preview: View>: UIViewRepresentable {
var content: Content
var preview: Preview
var menu: UIMenu
var navigate: () -> Void
init(content: Content, preview: Preview, menu: UIMenu, navigate: #escaping () -> Void) {
self.content = content
self.preview = preview
self.menu = menu
self.navigate = navigate
}
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
let hostView = UIHostingController(rootView: content)
hostView.view.translatesAutoresizingMaskIntoConstraints = false
let constraints = [
hostView.view.topAnchor.constraint(equalTo: view.topAnchor),
hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
hostView.view.widthAnchor.constraint(equalTo: view.widthAnchor),
hostView.view.heightAnchor.constraint(equalTo: view.heightAnchor)
]
view.addSubview(hostView.view)
view.addConstraints(constraints)
let interaction = UIContextMenuInteraction(delegate: context.coordinator)
view.addInteraction(interaction)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
class Coordinator: NSObject, UIContextMenuInteractionDelegate {
var parent: ContextMenuHelper
init(_ parent: ContextMenuHelper) {
self.parent = parent
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil) {
let previewController = UIHostingController(rootView: self.parent.preview)
return previewController
} actionProvider: { items in
return self.parent.menu
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
parent.navigate()
}
}
}
extension View {
func contextMenu<Preview: View>(navigate: #escaping () -> Void = {}, #ViewBuilder preview: #escaping () -> Preview, menu: #escaping () -> UIMenu) -> some View {
return CustomContextMenu(navigate: navigate, content: {self}, preview: preview, menu: menu)
}
}
struct CustomContextMenu<Content: View, Preview: View>: View {
var content: Content
var preview: Preview
var menu: UIMenu
var navigate: () -> Void
init(navigate: #escaping () -> Void, #ViewBuilder content: #escaping () -> Content, #ViewBuilder preview: #escaping () -> Preview, menu: #escaping () -> UIMenu) {
self.content = content()
self.preview = preview()
self.menu = menu()
self.navigate = navigate
}
var body: some View {
ZStack {
content
.overlay(ContextMenuHelper(content: content, preview: preview, menu: menu, navigate: navigate))
}
}
}
Usage:
.contextMenu(navigate: {
UIApplication.shared.open(url) //User tapped the preview
}) {
LinkView(link: url.absoluteString) //Preview
.environment(\.managedObjectContext, viewContext)
.accentColor(Color(hex: "59AF97"))
.environmentObject(variables)
}menu: {
let openUrl = UIAction(title: "Open", image: UIImage(systemName: "sidebar.left")) { _ in
withAnimation() {
UIApplication.shared.open(url)
}
}
let menu = UIMenu(title: url.absoluteString, image: nil, identifier: nil, options: .displayInline, children: [openUrl]) //Menu
return menu
}
For navigation:
add isActive: $navigate to your NavigationLink:
NavigationLink(destination: SomeView(), isActive: $navigate)
along with a new property:
#State var navigate = false
.contextMenu(navigate: {
navigate.toggle() //User tapped the preview
}) {
LinkView(link: url.absoluteString) //Preview
.environment(\.managedObjectContext, viewContext)
.accentColor(Color(hex: "59AF97"))
.environmentObject(variables)
}menu: {
let openUrl = UIAction(title: "Open", image: UIImage(systemName: "sidebar.left")) { _ in
withAnimation() {
UIApplication.shared.open(url)
}
}
let menu = UIMenu(title: url.absoluteString, image: nil, identifier: nil, options: .displayInline, children: [openUrl]) //Menu
return menu
}

There is a new method in iOS 16 SDK (currently in beta) that allows for showing a preview directly from SwiftUI without the need of tapping into the UIKit.
contextMenu(menuItems:preview:)

Related

Customize context menu item highlight color in SwiftUI

I have a button whose image has some additional clear space around it. The highlight when tapped regularly looks fine, and doesn't effect the clear space. However, when holding down to bring up the context menu, a light gray highlight is applied to the entire image area, which looks bad.
Is there a way to customize this highlight so the color is clear?
One possibility that I've tried but doesn't work:
.contentShape(.contextMenuPreview, Circle().size(width: 0, height: 0))
The problem here is that it causes the entire view to disappear during the context menu animation and visibility. I want to change the color of the highlight to clear, while maintaining the small resize animation and of course the visibility of the button.
SwiftUI doesn't offer an option to remove the background but with its UIKit counterpart UIContextMenuInteraction you can implement
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview?
And implement your own preview. It can look something like below.
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
print(#function)
interaction.view?.backgroundColor = .clear
let previewTarget = UIPreviewTarget(container: interaction.view!, center: interaction.view!.center)
let previewParams = UIPreviewParameters()
previewParams.backgroundColor = .clear
return .init(view: interaction.view!, parameters: previewParams, target: previewTarget)
}
You will get something like this with that code.
Below you will find a full implementation
struct CustomContextMenuView: View {
var body: some View {
Image(systemName: "person")
.resizable()
.frame(width: 100, height: 100)
.contextMenu(actions: [
UIAction(title: "Add to Favorites", image: UIImage(systemName: "heart.fill"), handler: { a in
print("Add to Favorites action")
})
], willEnd: {
//Called when the menu is dismissed
print("willEnd/onDismiss")
}, willDisplay: {
//Called when the menu appears
print("willDisplay/onAppear")
})
}
}
extension View {
func contextMenu(actions: [UIAction], willEnd: (() -> Void)? = nil, willDisplay: (() -> Void)? = nil) -> some View {
modifier(ContextMenuViewModifier(actions: actions, willEnd: willEnd, willDisplay: willDisplay))
}
}
struct ContextMenuViewModifier: ViewModifier {
let actions: [UIAction]
let willEnd: (() -> Void)?
let willDisplay: (() -> Void)?
func body(content: Content) -> some View {
Interaction_UI(view: {content}, actions: actions, willEnd: willEnd, willDisplay: willDisplay)
.fixedSize()
}
}
struct Interaction_UI<Content2: View>: UIViewRepresentable{
typealias UIViewControllerType = UIView
#ViewBuilder var view: Content2
let actions: [UIAction]
let willEnd: (() -> Void)?
let willDisplay: (() -> Void)?
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
func makeUIView(context: Context) -> some UIView {
let v = UIHostingController(rootView: view).view!
context.coordinator.contextMenu = UIContextMenuInteraction(delegate: context.coordinator)
v.addInteraction(context.coordinator.contextMenu!)
return v
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
class Coordinator: NSObject, UIContextMenuInteractionDelegate{
var contextMenu: UIContextMenuInteraction!
let parent: Interaction_UI
init(parent: Interaction_UI) {
self.parent = parent
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { [self]
suggestedActions in
return UIMenu(title: "", children: parent.actions)
})
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
print(#function)
parent.willDisplay?()
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
print(#function)
parent.willEnd?()
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
print(#function)
let previewParams = UIPreviewParameters()
previewParams.backgroundColor = .clear
return UITargetedPreview(view: interaction.view!, parameters: previewParams)
}
}
}
struct CustomContextMenuView_Previews: PreviewProvider {
static var previews: some View {
CustomContextMenuView()
}
}

Integrating Persona SDK in a SwiftUI View

I'm trying it integrate the Person SDK v2 in a SwiftUI view. It's setup for UIKit to present from a specific UIViewController. Here is my code.
https://docs.withpersona.com/docs/ios-sdk-v2-integration-guide
I'm not sure how to call my present function from SwiftUI. The SDK is setup so when you create that Inquiry object it triggers it's nav to present on the view controller.
struct PersonaInquiry: UIViewControllerRepresentable {
private var viewController = UIViewController()
private var coordinator = Coordinator()
class Coordinator: NSObject, InquiryDelegate {
func inquiryComplete(inquiryId: String, status: String, fields: [String : Persona2.InquiryField]) {
}
func inquiryCanceled(inquiryId: String?, sessionToken: String?) {
}
func inquiryError(_ error: Error) {
}
}
func makeUIViewController(context: Context) -> UIViewController {
return viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
}
func present(templateId: String) {
let config = InquiryConfiguration(templateId: templateId)
// Create the inquiry with the view controller
// as the delegate and presenter.
Inquiry(config: config, delegate: coordinator).start(from: viewController)
}
func makeCoordinator() -> Coordinator {
return coordinator
}
}
struct PersonaInquiry_Previews: PreviewProvider {
static var previews: some View {
PersonaInquiry()
}
}
Here's an example
ContentView:
struct ContentView: View {
#State private var isPresentingSDK = false
#State private var message = ""
var body: some View {
VStack {
Button(
action: {
isPresentingSDK.toggle()
},
label: {
Text("Launch Inquiry from SwiftUI 🚀")
.foregroundColor(Color.white)
.padding()
}
)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.fullScreenCover(
isPresented: $isPresentingSDK,
onDismiss: {
// Do nothing
},
content: {
InquirySDKWrapper(
inquiryComplete: { inquiryId, status, fields in
self.message = """
Inquiry Complete
Inquiry ID: \(inquiryId)
Status: \(String(describing: status))
"""
},
inquiryCanceled: { inquiryId, sessionToken in
self.message = "🤷‍♀️ Inquiry Cancelled"
},
inquiryErrored: { error in
self.message = """
💀 Inquiry Error
\(error.localizedDescription)
"""
}
)
}
)
Text(message)
.multilineTextAlignment(.center)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
InquirySDKWrapper:
import Persona2
import SwiftUI
import UIKit
struct InquirySDKWrapper: UIViewControllerRepresentable {
/// The wrapper VC presents the SDK and acts as its delegate.
/// The delegate methods in turn call the callbacks in the WrapperDelegate
private let wrapperVC: WrapperViewController
/// Pass in the callbacks for each delegate method
init(
inquiryComplete: #escaping (String, String, [String: InquiryField]) -> Void,
inquiryCanceled: #escaping (_ inquiryId: String?, _ sessionToken: String?) -> Void,
inquiryErrored: #escaping (_ error: Error) -> Void
) {
wrapperVC = WrapperViewController()
wrapperVC.inquiryComplete = inquiryComplete
wrapperVC.inquiryCanceled = inquiryCanceled
wrapperVC.inquiryErrored = inquiryErrored
}
func makeUIViewController(context: Context) -> some UIViewController {
return wrapperVC
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
// Do nothing
}
}
final class WrapperViewController: UIViewController {
private var isPresenting = false
// The callbacks
var inquiryComplete: ((_ inquiryId: String, _ status: String, _ fields: [String: InquiryField]) -> Void)!
var inquiryCanceled: ((_ inquiryId: String?, _ sessionToken: String?) -> Void)!
var inquiryErrored: ((_ error: Error) -> Void)!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Otherwise this would trigger once the SDK exits too
guard !isPresenting else { return }
let inquiry = Inquiry(
config: InquiryConfiguration(
templateId: "YOUR TEMPLATE ID HERE"
),
delegate: self
)
inquiry.start(from: self)
isPresenting = true
}
}
extension WrapperViewController: InquiryDelegate {
func inquiryComplete(inquiryId: String, status: String, fields: [String: InquiryField]) {
inquiryComplete(inquiryId, status, fields)
dismiss(animated: true, completion: nil)
}
func inquiryCanceled(inquiryId: String?, sessionToken: String?) {
inquiryCanceled(inquiryId, sessionToken)
dismiss(animated: true, completion: nil)
}
func inquiryError(_ error: Error) {
inquiryErrored(error)
dismiss(animated: true, completion: nil)
}
}

How present view over any view in swiftui

I'm implementing a chat application and I have an incoming call
so I have presented an incoming call screen when user Accept I have implemented by the notification center
public extension Notification.Name {
static let incomingCallView = Notification.Name("incomingCallView")
}
struct ModalData {
let onDismiss: (() -> Void)?
let content: (Binding<Bool>) -> AnyView
init<Content: View>(onDismiss: (() -> Void)? = nil,
#ViewBuilder content: #escaping (Binding<Bool>) -> Content) {
self.onDismiss = onDismiss
self.content = { AnyView(content($0)) }
}
static let empty = ModalData { _ in EmptyView() }
}
and this in provider delegate
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
action.fail()
return
}
callOption.ringingPeriod?.invalidate()
callOption.ringingPeriod = nil
socket.inizalizeWebrtc(webRTCClient: WebRTCClient(iceServers: Config.default.webRTCIceServers))
socket.webRTCClient?.offer {(sdp) in
// self.hasLocalSdp = true
self.socket.sendSdp(sdp: sdp, type: "offer", callType: self.callOption.callType == .videoCall
? "video"
: "audio"
)
}
call.answer()
NotificationCenter.default.post(name: .incomingCallView,
object: ModalData(onDismiss: {
}) { isPresented in
IncomingCallScreen(callScreenIsPresented: isPresented)
})
action.fulfill()
}
the problem that I have to add .onReceive in all views to open the incoming call screen if I didn't add .onrecieve it will not present
.onReceive(NotificationCenter.default.publisher(for: .incomingCallView)) { notif in
if let data = notif.object as? ModalData {
modalData = data
incomingCallView = true
}
}
.fullScreenCover(isPresented: $incomingCallView,
onDismiss: modalData.onDismiss) {
modalData.content($incomingCallView)
}
How open it without adding .onReceive in all views is there another solution

Add action to button created using UIViewRepresentable

I am using a FluentUI#button, which behind uses UIKit
I need to display that button in a SwiftUI View, and I'm trying to toggle an #State property or add a #selector to the button, but I'm not able to do it
I created a generic UIViewRepresentable structure to help me embed any UIView in my SwiftUI Views, following this tutorial:
struct Anything<Wrapper : UIView>: UIViewRepresentable {
typealias Updater = (Wrapper, Context) -> Void
var makeView: () -> Wrapper
var update: (Wrapper, Context) -> Void
init(_ makeView: #escaping #autoclosure () -> Wrapper,
updater update: #escaping (Wrapper) -> Void) {
self.makeView = makeView
self.update = { view, _ in update(view) }
}
func makeUIView(context: Context) -> Wrapper {
makeView()
}
func updateUIView(_ view: Wrapper, context: Context) {
update(view, context)
}
}
And I have the following code:
import SwiftUI
import FluentUI
struct MyView: View {
#State var isGreen = true
var body: some View {
VStack {
Text("Hello, World!")
.background(isGreen ? Color.green : Color.blue)
Spacer().frame(height: 20)
Anything(FluentUI.Button(style: .primaryFilled)) {
$0.setTitle("Try me!", for: .normal)
}
.frame(height: 30)
.padding()
}
}
}
struct Anything<Wrapper: UIView>: UIViewRepresentable {
typealias Updater = (Wrapper, Context) -> Void
var makeView: () -> Wrapper
var update: (Wrapper, Context) -> Void
var action: (() -> Void)?
init(_ makeView: #escaping #autoclosure () -> Wrapper,
updater update: #escaping (Wrapper) -> Void) {
self.makeView = makeView
self.update = { view, _ in update(view) }
}
func makeUIView(context: Context) -> Wrapper {
makeView()
}
func updateUIView(_ view: Wrapper, context: Context) {
update(view, context)
}
}
struct SwiftUIView_Previews: PreviewProvider {
static var previews: some View {
MyView()
}
}
And if I try to add this:
$0.addTarget(self, action: #selector(toggleColor), for: .touchUpInside)
With:
func toggleColor() {
isGreen = !isGreen
}
I get this error:
Argument of '#selector' refers to instance method 'toggleColor()' that is not exposed to Objective-C
And if I add #objc to the method I get this error:
#objc can only be used with members of classes, #objc protocols, and concrete extensions of classes
And as my Anything struct isn't a Button from SwiftUI, I cannot add the action parameter as normally
How can I add a target/action to my button in this way?
Here is a demo of possible solution - we need a wrapper between UIKit objective-c selectors and SwiftUI swift function.
Tested with Xcode 13.3 / iOS 15.4
Here is main part (used UIButton instead of FluentUI.Button for simplicity):
Anything(UIButton(type: .system)) {
$0.setTitle("Try me!", for: .normal)
$0.addTarget(toggleColor, action: #selector(Action.perform(sender:)), for: .touchUpInside)
toggleColor.action = {
isGreen.toggle()
}
}
Complete test module is here

Is there a SwiftUI equivalent for viewWillDisappear(_:) or detect when a view is about to be removed?

In SwiftUI, I'm trying to find a way to detect that a view is about to be removed only when using the default navigationBackButton. Then perform some action.
Using onDisappear(perform:) acts like viewDidDisappear(_:), and the action performs after another view appears.
Or, I was thinking the above problem might be solved by detecting when the default navigationBarBackButton is pressed. But I've found no way to detect that.
Is there any solution to perform some action before another view appears?
(I already know it is possible to do that by creating a custom navigation back button to dismiss a view)
Here is approach that works for me, it is not pure-SwiftUI but I assume worth posting
Usage:
SomeView()
.onDisappear {
print("x Default disappear")
}
.onWillDisappear { // << order does NOT matter
print(">>> going to disappear")
}
Code:
struct WillDisappearHandler: UIViewControllerRepresentable {
func makeCoordinator() -> WillDisappearHandler.Coordinator {
Coordinator(onWillDisappear: onWillDisappear)
}
let onWillDisappear: () -> Void
func makeUIViewController(context: UIViewControllerRepresentableContext<WillDisappearHandler>) -> UIViewController {
context.coordinator
}
func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<WillDisappearHandler>) {
}
typealias UIViewControllerType = UIViewController
class Coordinator: UIViewController {
let onWillDisappear: () -> Void
init(onWillDisappear: #escaping () -> Void) {
self.onWillDisappear = onWillDisappear
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
onWillDisappear()
}
}
}
struct WillDisappearModifier: ViewModifier {
let callback: () -> Void
func body(content: Content) -> some View {
content
.background(WillDisappearHandler(onWillDisappear: callback))
}
}
extension View {
func onWillDisappear(_ perform: #escaping () -> Void) -> some View {
self.modifier(WillDisappearModifier(callback: perform))
}
}
You can bind the visibility of the child view to some state, and monitor that state for changes.
When the child view is pushed, the onChange block is called with show == true. When the child view is popped, the same block is called with show == false:
struct ParentView: View {
#State childViewShown: Bool = false
var body: some View {
NavigationLink(destination: Text("child view"),
isActive: self.$childViewShown) {
Text("show child view")
}
.onChange(of: self.childViewShown) { show in
if show {
// child view is appearing
} else {
// child view is disappearing
}
}
}
}
Here's a slightly more succinct version of the accepted answer:
private struct WillDisappearHandler: UIViewControllerRepresentable {
let onWillDisappear: () -> Void
func makeUIViewController(context: Context) -> UIViewController {
ViewWillDisappearViewController(onWillDisappear: onWillDisappear)
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
private class ViewWillDisappearViewController: UIViewController {
let onWillDisappear: () -> Void
init(onWillDisappear: #escaping () -> Void) {
self.onWillDisappear = onWillDisappear
super.init(nibName: nil, bundle: nil)
}
#available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
onWillDisappear()
}
}
}
extension View {
func onWillDisappear(_ perform: #escaping () -> Void) -> some View {
background(WillDisappearHandler(onWillDisappear: perform))
}
}
you have a couple of actions for each object that you want to show on the screen
func onDisappear(perform action: (() -> Void)? = nil) -> some View
//Adds an action to perform when this view disappears.
func onAppear(perform action: (() -> Void)? = nil) -> some View
//Adds an action to perform when this view appears.
you can use the like the sample ( in this sample it affects on the VStack):
import SwiftUI
struct TestView: View {
#State var textObject: String
var body: some View {
VStack {
Text(textObject)
}
.onAppear {
textObject = "Vertical stack is appeared"
}
.onDisappear {
textObject = ""
}
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
TestView()
}
}
}
You can trigger the change of the #Environment .scenePhase like this :
struct YourView: View {
#Environment(\.scenePhase) var scenePhase
var body: Some View {
VStack {
// Your View code
}
.onChange(of: scenePhase) { phase in
switch phase {
case .active:
print("active")
case .inactive:
print("inactive")
case .background:
print("background")
#unknown default:
print("?")
}
}
}
}

Resources