Post

Replies

Boosts

Views

Activity

Reply to Does UITextInteraction have a way to dismiss the edit menu?
I created an extension to UITextInput that adds methods to let you show/hide the menu. This assumes the custom UITextInput is making use of UITextInteraction. The following works for iOS 15+ (tested with iOS 15.4, 16.4, 17.4, and 17.5). This has only been tested in development. It arguably uses a private API so I don't know (yet) if this will be approved for App Store apps. extension UITextInput { @available(iOS 16.0, *) var editMenuInteraction: UIEditMenuInteraction? { return textInputView?.interactions.first { $0 is UIEditMenuInteraction } as? UIEditMenuInteraction } func showEditMenu() { if let textInputView { if #available(iOS 16.0, *) { // There's no direct API to show the menu. Normally you setup a UIEditMenuInteraction but the // UITextInteraction sets up its own. So we need to find that interaction and call its // presentEditMenu with a specially crafted UIEditMenuConfiguration. if let interaction = self.editMenuInteraction { if let selectedRange = self.selectedTextRange { let rect = self.firstRect(for: selectedRange) if !rect.isNull { let pt = CGPoint(x: rect.midX, y: rect.minY - textInputView.frame.origin.y) // !!!: Possible future failure // This magic string comes from: // -[UITextContextMenuInteraction _querySelectionCommandsForConfiguration:suggestedActions:completionHandler:] // It can be seen by looking at the assembly for the _querySelectionCommandsForConfiguration method. // About 24 lines down is a reference to this string literal. let cfg = UIEditMenuConfiguration(identifier: "UITextContextMenuInteraction.TextSelectionMenu", sourcePoint: pt) interaction.presentEditMenu(with: cfg) } } } } else { if let selectedRange = self.selectedTextRange { let rect = self.firstRect(for: selectedRange) if !rect.isNull { UIMenuController.shared.showMenu(from: textInputView, rect: rect.insetBy(dx: 0, dy: -textInputView.frame.origin.y)) } } } } } func hideEditMenu() { if let textInputView { if #available(iOS 16.0, *) { if let interaction = self.editMenuInteraction { interaction.dismissMenu() } } else { if UIMenuController.shared.isMenuVisible { UIMenuController.shared.hideMenu(from: textInputView) } } } } }
May ’24
Reply to How do you get the cursor to appear programmatically in a custom UITextInput with UITextInteraction?
After lots of digging, I've finally come up with a solution that works under iOS 15 - iOS 17 (tested with iOS 15.4, 16.4, 17.4, and 17.5). Under iOS 17, the use of UITextSelectionDisplayInteraction activated only worked if my custom UITextInput already had some text in it. I don't know fi that's an issue with my custom input view or not but since my hack for iOS 15/16 also worked with iOS 17 I didn't spend any time trying to figure it out. I created an extension to UITextInput that add a method to activate the cursor that you can call from the becomeFirstResponder of the custom input view. It makes use of a class that fakes a tap gesture enough to make the code work. This is an ugly hack that makes use of several private APIs. It works in development. I've made no attempt to use this code in a production App Store app. @objcMembers class MyFakeTap: NSObject { private let myView: UIView init(view: UIView) { self.myView = view super.init() } func tapCount() -> Int { return 1 } func touchesForTap() -> [UITouch] { return [] } var view: UIView? { myView } var state: Int { get { return 1 } set { } } func locationInView(_ view: UIView?) -> CGPoint { return .init(x: 5, y: 5) } } extension UITextInput { private var textSelectionInteraction: UITextInteraction? { if let clazz = NSClassFromString("UITextSelectionInteraction") { return textInputView?.interactions.first { $0.isKind(of: clazz) } as? UITextInteraction } else { return nil } } func activateCursor() { if let textSelectionInteraction { let tap = MyFakeTap(view: self.textInputView ?? UIView()) textSelectionInteraction.perform(NSSelectorFromString("_handleMultiTapGesture:"), with: tap) } } } Note that under iOS 15, if you call activateCursor() from becomeFirstResponder, be sure you only do so if the view is not already the first responder. Otherwise you will end up in an infinite loop of calls to becomeFirstResponder. override func becomeFirstResponder() -> Bool { // Under iOS 15 we end up in an infinite loop due to activeCursor indirectly calling becomeFirstResponder. // So don't go any further if already the first responder. if isFirstResponder { return true } let didBecomeResponder = super.becomeFirstResponder() if didBecomeResponder { self.activateCursor() } return didBecomeResponder }
May ’24
Reply to Reading Codable properties raised a fatal error.
It seems that SwiftData and Codable model properties are seriously broken (at least as of Xcode 15.0 beta 3). I'm getting the same error shown in the above question for any model property whose type is anything marked as Codable. I am finding that in many cases the custom encode method is never called when storing a value in an instance of the model. And when reading the property, the init(from:) isn't encoded as expected. I was able to get things to work for CLLocationCoordinate2D with the following implementation: extension CLLocationCoordinate2D: Codable { // These exact case values must be used to decode a location enum CodingKeys: CodingKey { case latitude case longitude } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let lat = try container.decode(CLLocationDegrees.self, forKey: .latitude) let lon = try container.decode(CLLocationDegrees.self, forKey: .longitude) self = CLLocationCoordinate2D(latitude: lat, longitude: lon) } // Never called by SwiftData public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.latitude, forKey: .latitude) try container.encode(self.longitude, forKey: .longitude) } }
Jul ’23
Reply to Mac Catalyst How to Play NSBeep
NSBeep() and AudioServicesPlayAlertSound(kSystemSoundID_UserPreferredAlert) seem to play the same sound. kSystemSoundID_UserPreferredAlert isn't available under Mac Catalyst but it has a value of 0x00001000. The following code works under Mac Catalyst (at least in my tests with macOS 13.4). AudioServicesPlayAlertSound(0x00001000); // same sound as NSBeep() You may need to import AudioToolbox.
Jul ’23
Reply to How is receipt validation supposed to work for a new Mac version of an old iOS app?
OK, let's say I check the original purchase date instead of the original purchase version. I still don't know what is supposed to happen between the iOS and macOS versions of the app. If a user purchased some earlier version of the iOS app (pre or post in-app purchases), should the receipt received in the macOS version of the app reflect that past purchase? Right now I know of one user where the receipt obtained in the macOS version makes no mention of the past iOS purchase. The user has confirmed they are using the same AppleID on both devices. As extra info, my app only does receipt validation if the user clicks on the "restore purchases" button in the app. Is there any reason that waiting until then to request a new receipt would cause the wrong receipt to be received?
Jun ’23
Reply to How to open other apps from my app similar to Launcher app. canOpenURL, LSApplicationQueriesSchemes
LSApplicationQueriesSchemes is only used with canOpenURL. It's not used with openURL. So you can open all of the apps you want. You are just limited if you want to check for installed apps with canOpenURL. From the docs for canOpenURL: Unlike this method, the openURL:options:completionHandler: method isn’t constrained by the LSApplicationQueriesSchemes requirement. If an app is available to handle the URL, the system will launch it, even if you haven’t declared the scheme.
Jun ’23
Reply to Why aren't new builds sent to App Store Connect reflecting my updated Info.plist?
tl;dr - I unknowingly had a localized Info.plist strings file in my project and the only string in it was the Contacts privacy string. Once the strings file was deleted, the proper privacy string began to appear as expected. This issue was driving me crazy. No matter what I did to change the Info.plist, the reviewers were seeing the old privacy string. I finally broke down and pushed an updated build of my iOS app to Test Flight. On one of my iOS device I deleted the app and reset Location and Privacy settings. I installed the iOS app from Test Flight and to my shock, the old privacy string was appearing just like it did for the reviewers. Until the Mac version of the app was rejected a few days again, I hadn't updated any of the app's privacy strings in years. It was never an issue. So then I deleted the app and reset Location and Privacy settings again but this time I did a plain development build from Xcode. And sure enough, the old privacy string appeared again. Why? The Info.plist clearly has the updated privacy string. I was dumbfounded. What is going on? I finally did a deep search of the project for part of the message and there it was. InfoPlist.strings with just that one value in it. So no matter what I did to Info.plist, the value in Info.strings was being used. I have no idea how it was ever created or when. Looking at git it seems it was somehow created and added just 3 weeks ago. Very odd. Anyway, if you ever have trouble with your privacy strings not updating, check for an InfoPlist.strings file in your project.
Jun ’23
Reply to "Import from iPhone or iPad" in UITextView context menu under Mac Catalyst
Success! (mostly). I finally found a way to remove the "Take a Photo", "Scan Documents", and "Add Sketch" menus. The solution doesn't result in any stack traces either which is nice. The only minor piece left is that this solution does leave the grayed out device name menu item in the context menu. And if there is more than one device, the "Import from iPhone or iPad" menu still appears but only with the grayed out device names. Here's the code that removes the three unwanted menu items (for each device): override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if action == Selector(("importFromDevice:")) { // sender will be an NSMenuItem. There will be one each for the 3 menus we are trying to hide if let menu = sender as? NSObject, menu.responds(to: Selector(("setHidden:"))) { menu.setValue(true, forKey: "hidden") } return false } return super.canPerformAction(action, withSender: sender) } Add this to any view controller that has a UITextView to eliminate the 3 context menu options. Or create a custom subclass of UITextView and put the code in there. Then use the custom text view class anywhere you don't want these menus.
Jun ’23
Reply to "Import from iPhone or iPad" in UITextView context menu under Mac Catalyst
One more slight step forward. I have found a way to make the "Take Photo", "Scan Documents", and "Add Sketch" menus do nothing. It's still a poor user experience having the menus at all, but at least now the user doesn't get to go through all of the motions of taking a photo or scanning a document with their iOS device just to have the results ignored. I was able to make this happen by adding the following code: @objc func importFromDevice(_ sender: Any) { // no-op } override func target(forAction action: Selector, withSender sender: Any?) -> Any? { if action == #selector(importFromDevice) { return self } return super.target(forAction: action, withSender: sender) } You can add this to a custom UITextView subclass or to the view controller class containing a text view. The only issue with this code is that it results in assertion failures and big old stack traces in the console each time the menu gets validated. There's a message about: -[UINSResponderProxy validateMenuItem:]: We're being asked to validate a menu item whose proxy isn't the one we wrapped. But I've tested via TestFlight and the app continues to run just fine.
May ’23
Reply to "Import from iPhone or iPad" in UITextView context menu under Mac Catalyst
I have not found any way to remove the "Import from iPhone or iPad" menu. But I have at least found a way to prevent the ensuing image from being added as an attachment. When a user selects a menu, such as "Take Photo", and then takes a picture with their iOS device and chooses "Use Photo", the UITextViewDelegate textView(_:shouldChangeTextIn:replacementText:) delegate method is called. The replacement text will contain the character NSTextAttachment.character. So to prevent the picture from being added as an attachment to the text view, return false if you find that the replacement text contains NSTextAttachment.character. It's still a terrible user experience. The user sees the menu, goes through all of the steps to take a photo, and then nothing happens. It would be so much better if there was a way to remove the menu when not wanted.
May ’23
Reply to How to get the AVCam demo app to work under Mac Catalyst?
Overdue update - The AVCam demo now runs under Mac Catalyst. Much of AVFoundation has been supported since Mac Catalyst 14.0. The AVCam demo needs a few small changes to build for Mac Catalyst. By default, the project is not setup with a Mac Catalyst destination. It only supports iPad apps running on Apple Silicon. Delete the "Mac (Designed for iPad)" destination and add the "Mac (Mac Catalyst)" destination. You may also have to change the macOS Deployment target in the Build Settings depending on the version of macOS running on your Mac. Thanks Apple for adding support for AVFoundation. Now I just need to figure out if I can support Camera Continuity under Mac Catalyst.
May ’23
Reply to How to modify context menu shown in a UITextView?
I recently figured out a way to remove the "Show Fonts" and "Show Colors" menus from the context menu of a UITextView. This only works on iOS 16.0/Mac Catalyst 16.0 or later. The following is my Objective-C code. Should be easy enough to translate to Swift. I started by adding new method to UIMenu: Category declaration: @interface UIMenu (Additions) - (UIMenu *)menuByRemovingChildWithAction:(SEL)action; @end Category implementation: @implementation UIMenu (Additions) - (UIMenu *)menuByRemovingChildWithAction:(SEL)action { NSArray<UIMenuElement *> *children = self.children; for (NSInteger i = 0; i < children.count; i++) { UIMenuElement *element = children[i]; if ([element isKindOfClass:[UICommand class]]) { UICommand *cmd = (UICommand *)element; if (cmd.action == action) { NSMutableArray *newChildren = [children mutableCopy]; [newChildren removeObjectAtIndex:i]; if (newChildren.count == 0) { return nil; } else { return [self menuByReplacingChildren:newChildren]; } } } else if ([element isKindOfClass:[UIMenu class]]) { UIMenu *menu = (UIMenu *)element; UIMenu *newMenu = [menu menuByRemovingChildWithAction:action]; if (newMenu == nil) { NSMutableArray *newChildren = [children mutableCopy]; [newChildren removeObjectAtIndex:i]; return [self menuByReplacingChildren:newChildren]; } else if (newMenu != menu) { NSMutableArray *newChildren = [children mutableCopy]; newChildren[i] = newMenu; return [self menuByReplacingChildren:newChildren]; } } } return self; } @end This recursively goes through a menu hierarchy and removes any menu item that is a UICommand with the given action. With that in place you need to implement the iOS 16.0 UITextViewDelegate method: - (UIMenu *)textView:(UITextView *)textView editMenuForTextInRange:(NSRange)range suggestedActions:(NSArray<UIMenuElement *> *)suggestedActions API_AVAILABLE(ios(16.0)) { UIMenu *menu = [UIMenu menuWithChildren:suggestedActions]; menu = [menu menuByRemovingChildWithAction:NSSelectorFromString(@"toggleFontPanel:")]; menu = [menu menuByRemovingChildWithAction:NSSelectorFromString(@"orderFrontColorPanel:")]; return menu; } That's it. All other attempts failed. I was able to disable the "Show Fonts" menu by overriding canPerformAction:sender: in the App Delegate. But for some reason it did nothing for the "Show Colors" menu. - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { if (action == NSSelectorFromString(@"toggleFontPanel:") || action == NSSelectorFromString(@"orderFrontColorPanel:")) { return NO; } BOOL res = [super canPerformAction:action withSender:sender]; return res; }
May ’23