How do I debug SwiftUI AttributeGraph cycle warnings? - xcode

I'm getting a lot of AttributeGraph cycle warnings in my app that uses SwiftUI. Is there any way to debug what's causing it?
This is what shows up in the console:
=== AttributeGraph: cycle detected through attribute 11640 ===
=== AttributeGraph: cycle detected through attribute 14168 ===
=== AttributeGraph: cycle detected through attribute 14168 ===
=== AttributeGraph: cycle detected through attribute 44568 ===
=== AttributeGraph: cycle detected through attribute 3608 ===

The log is generated by (from private AttributeGraph.framework)
AG::Graph::print_cycle(unsigned int) const ()
so you can set symbolic breakpoint for print_cycle
and, well, how much it could be helpful depends on your scenario, but definitely you'll get error generated stack in Xcode.

For me this issue was caused by me disabling a text field while the user was still editing it.
To fix this, you must first resign the text field as the first responder (thus stopping editing), and then disable the text field.
I explain this more in this Stack Overflow answer.

For me, this issue was caused by trying to focus a TextField right before changing to the tab of a TabView containing the TextField.
It was fixed by simply focusing the TextField after changing the TabView tab.
This seems similar to what #wristbands was experiencing.

For me the issue was resolved by not using UIActivityIndicator... not sure why though. The component below was causing problems.
public struct UIActivityIndicator: UIViewRepresentable {
private let style: UIActivityIndicatorView.Style
/// Default iOS 11 Activity Indicator.
public init(
style: UIActivityIndicatorView.Style = .large
) {
self.style = style
}
public func makeUIView(
context: UIViewRepresentableContext<UIActivityIndicator>
) -> UIActivityIndicatorView {
return UIActivityIndicatorView(style: style)
}
public func updateUIView(
_ uiView: UIActivityIndicatorView,
context: UIViewRepresentableContext<UIActivityIndicator>
) {}
}

#Asperi Here is a minimal example to reproduce AttributeGraph cycle:
import SwiftUI
struct BoomView: View {
var body: some View {
VStack {
Text("Go back to see \"AttributeGraph: cycle detected through attribute\"")
.font(.title)
Spacer()
}
}
}
struct TestView: View {
#State var text: String = ""
#State private var isSearchFieldFocused: Bool = false
var placeholderText = NSLocalizedString("Search", comment: "")
var body: some View {
NavigationView {
VStack {
FocusableTextField(text: $text, isFirstResponder: $isSearchFieldFocused, placeholder: placeholderText)
.foregroundColor(.primary)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
NavigationLink(destination: BoomView()) {
Text("Boom")
}
Spacer()
}
.onAppear {
self.isSearchFieldFocused = true
}
.onDisappear {
isSearchFieldFocused = false
}
}
}
}
FocusableTextField.swift based on https://stackoverflow.com/a/59059359/659389
import SwiftUI
struct FocusableTextField: UIViewRepresentable {
#Binding public var isFirstResponder: Bool
#Binding public var text: String
var placeholder: String = ""
public var configuration = { (view: UITextField) in }
public init(text: Binding<String>, isFirstResponder: Binding<Bool>, placeholder: String = "", configuration: #escaping (UITextField) -> () = { _ in }) {
self.configuration = configuration
self._text = text
self._isFirstResponder = isFirstResponder
self.placeholder = placeholder
}
public func makeUIView(context: Context) -> UITextField {
let view = UITextField()
view.placeholder = placeholder
view.autocapitalizationType = .none
view.autocorrectionType = .no
view.addTarget(context.coordinator, action: #selector(Coordinator.textViewDidChange), for: .editingChanged)
view.delegate = context.coordinator
return view
}
public func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = text
switch isFirstResponder {
case true: uiView.becomeFirstResponder()
case false: uiView.resignFirstResponder()
}
}
public func makeCoordinator() -> Coordinator {
Coordinator($text, isFirstResponder: $isFirstResponder)
}
public class Coordinator: NSObject, UITextFieldDelegate {
var text: Binding<String>
var isFirstResponder: Binding<Bool>
init(_ text: Binding<String>, isFirstResponder: Binding<Bool>) {
self.text = text
self.isFirstResponder = isFirstResponder
}
#objc public func textViewDidChange(_ textField: UITextField) {
self.text.wrappedValue = textField.text ?? ""
}
public func textFieldDidBeginEditing(_ textField: UITextField) {
self.isFirstResponder.wrappedValue = true
}
public func textFieldDidEndEditing(_ textField: UITextField) {
self.isFirstResponder.wrappedValue = false
}
}
}

For me the issue was that I was dynamically loading the AppIcon asset from the main bundle. See this Stack Overflow answer for in-depth details.

I was using enum cases as tag values in a TabView on MacOS. The last case (of four) triggered three attributeGraph cycle warnings. (The others were fine).
I am now using an Int variable (InspectorType.book.typeInt instead of InspectorType.book) as my selection variable and the cycle warnings have vanished.
(I can demonstrate this by commenting out the offending line respectively by changing the type of my selection; I cannot repeat it in another app, so there's obviously something else involved; I just haven't been able to identify the other culprit yet.)

Related

UIDocumentPickerViewController with Catalyst on MACOS

I have this code for show picker with Catalyst in MacOS:
final class DocumentPicker: NSObject, UIViewControllerRepresentable, ObservableObject {
typealias UIViewControllerType = UIDocumentPickerViewController
#Published var urlsPicked = [URL]()
lazy var viewController:UIDocumentPickerViewController = {
// For picked only folder
let vc = UIDocumentPickerViewController(documentTypes: ["public.folder"], in: .open)
vc.allowsMultipleSelection = false
vc.delegate = self
return vc
}()
........
and:
struct ContentView: View {
#ObservedObject var picker = DocumentPicker()
#State private var urlPick = ""
var body: some View {
HStack {
Text(urlPicked())
.padding()
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 1)
)
TextField("", text: $urlPick)
.textFieldStyle(RoundedBorderTextFieldStyle())
.font(.system(size: 10))
.disabled(true)
Spacer()
Button(action: {
#if targetEnvironment(macCatalyst)
let viewController = UIApplication.shared.windows[0].rootViewController!
viewController.present(self.picker.viewController, animated: true)
self.picker.objectWillChange.send()
#endif
print("Hai premuto il pulsante per determinare il path della GeoFolder")
}) {
Image(systemName: "square.and.arrow.up")
}
}
.padding()
}
private func urlPicked() -> String {
var urlP = ""
if picker.urlsPicked.count > 0 {
urlP = picker.urlsPicked[0].path
urlPick = picker.urlsPicked[0].path
}
return urlP
}
}
If I run the above code I get the chosen correct path in text, while in textfield nothing and also I have the error in urlPick = picker.urlsPicked[0].path: Modifying state during view update, this will cause undefined behavior.
How can I modify the code to show the correct path chosen also in textfield?
have the error in urlPick = picker.urlsPicked[0].path: Modifying state
during view update, this will cause undefined behavior. How can I
modify the code to show the correct path chosen also in textfield?
Try the following
if picker.urlsPicked.count > 0 {
urlP = picker.urlsPicked[0].path
DispatchQueue.main.async {
urlPick = picker.urlsPicked[0].path
}
}
For anyone seeking to create a MacOS Document Picker with READ-ONLY entitlements, please use the following solution:
import Foundation
import UIKit
extension ViewController: UIDocumentBrowserViewControllerDelegate, UIDocumentPickerDelegate {
#objc func presentDocumentPicker() {
if operatingSystem == .macintosh {
let documentPicker = UIDocumentBrowserViewController(forOpening: [.pdf])
documentPicker.delegate = self
documentPicker.allowsDocumentCreation = false
documentPicker.allowsPickingMultipleItems = false
// Present the document picker.
present(documentPicker, animated: true, completion: nil)
} else {
let documentsPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.pdf])
documentsPicker.delegate = self
documentsPicker.allowsMultipleSelection = false
documentsPicker.modalPresentationStyle = .fullScreen
self.present(documentsPicker, animated: true, completion: nil)
}
}
func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentsAt documentURLs: [URL]) {
guard let url = documentURLs.first, url.startAccessingSecurityScopedResource() else { return }
defer {
DispatchQueue.main.async {
url.stopAccessingSecurityScopedResource()
}
}
debugPrint("[DocumentPicker] Selected Item with URL : ", url)
controller.dismiss(animated: true)
}
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first, url.startAccessingSecurityScopedResource() else { return }
defer {
DispatchQueue.main.async {
url.stopAccessingSecurityScopedResource()
}
}
debugPrint("[DocumentPicker] Selected Item with URL : ", url)
controller.dismiss(animated: true)
}
public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
controller.dismiss(animated: true)
}
}
Please note that in the event that the entitlements are read-write (i.e. you are also allowing the user to save files to the computer) - then you can simply use a UIDocumentPicker (the non .macintosh example in my snippet).

How to display Game Center leaderboard with SwiftUI

I created a tester app to test adding a GameCenter leaderboard to a simple SwiftUI game I am creating. I have been unable to figure out how to display the Game Center leaderboard with all the scores.
I have created a class containing all the Game Center functions (authentication and adding score to the leaderboard. This is called from the main ContentView view. I can't figure out how to make it show the leaderboard (or even the gamecenter login screen if the player isn't already logged in.)
This is my GameCenterManager class:
class GameCenterManager {
var gcEnabled = Bool() // Check if the user has Game Center enabled
var gcDefaultLeaderBoard = String() // Check the default leaderboardID
var score = 0
let LEADERBOARD_ID = "grp.colorMatcherLeaderBoard_1" //Leaderboard ID from Itunes Connect
// MARK: - AUTHENTICATE LOCAL PLAYER
func authenticateLocalPlayer() {
let localPlayer: GKLocalPlayer = GKLocalPlayer.local
localPlayer.authenticateHandler = {(ViewController, error) -> Void in
if((ViewController) != nil) {
print("User is not logged into game center")
} else if (localPlayer.isAuthenticated) {
// 2. Player is already authenticated & logged in, load game center
self.gcEnabled = true
// Get the default leaderboard ID
localPlayer.loadDefaultLeaderboardIdentifier(completionHandler: { (leaderboardIdentifer, error) in
if error != nil { print(error ?? "error1")
} else { self.gcDefaultLeaderBoard = leaderboardIdentifer! }
})
print("Adding GameCenter user was a success")
} else {
// 3. Game center is not enabled on the users device
self.gcEnabled = false
print("Local player could not be authenticated!")
print(error ?? "error2")
}
}
} //authenticateLocalPlayer()
func submitScoreToGC(_ score: Int){
let bestScoreInt = GKScore(leaderboardIdentifier: LEADERBOARD_ID)
bestScoreInt.value = Int64(score)
GKScore.report([bestScoreInt]) { (error) in
if error != nil {
print(error!.localizedDescription)
} else {
print("Best Score submitted to your Leaderboard!")
}
}
}//submitScoreToGc()
}
and here is the ContentView struct:
struct ContentView: View {
//GameCenter
init() {
self.gameCenter = GameCenterManager()
self.gameCenter.authenticateLocalPlayer()
}
#State var score = 0
var gcEnabled = Bool() //Checks if the user had enabled GameCenter
var gcDefaultLeaderboard = String() //Checks the default leaderboard ID
let gameCenter: GameCenterManager
/*End GameCenter Variables */
var body: some View {
HStack {
Text("Hello, world!")
Button(action: {
self.score += 1
print("Score increased by 10. It is now \(self.score)")
self.gameCenter.submitScoreToGC(self.score)
}) {
Text("Increase Score")
}
}
}
}
Would greatly appreciate any help in fixing the problem.
I have a fix.
I use Game Center successfully in my SwiftUI App Sound Matcher. Code snippets to follow.
The code doesn't exactly follow the SwiftUI declarative philosophy but it works perfectly. I added snippets to SceneDelegate and ContentView plus used I used a GameKitHelper class similar to the one Thomas created for his test app. I based my version on code I found on raywenderlich.com.
I actually tried using a struct conforming to UIViewControllerRepresentable as my first attempt, following the same line of thought as bg2b, however it kept complaining that the game centre view controller needed to be presented modally. Eventually I gave up and tried my current more successful approach.
For SwiftUI 1.0 and iOS 13
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
let contentView = ContentView()
.environmentObject(GameKitHelper.sharedInstance) // publish enabled state
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
// new code to create listeners for the messages
// you will be sending later
PopupControllerMessage.PresentAuthentication
.addHandlerForNotification(
self,
handler: #selector(SceneDelegate
.showAuthenticationViewController))
PopupControllerMessage.GameCenter
.addHandlerForNotification(
self,
handler: #selector(SceneDelegate
.showGameCenterViewController))
// now we are back to the standard template
// generated when your project was created
self.window = window
window.makeKeyAndVisible()
}
}
// pop's up the leaderboard and achievement screen
#objc func showGameCenterViewController() {
if let gameCenterViewController =
GameKitHelper.sharedInstance.gameCenterViewController {
self.window?.rootViewController?.present(
gameCenterViewController,
animated: true,
completion: nil)
}
}
// pop's up the authentication screen
#objc func showAuthenticationViewController() {
if let authenticationViewController =
GameKitHelper.sharedInstance.authenticationViewController {
self.window?.rootViewController?.present(
authenticationViewController, animated: true)
{ GameKitHelper.sharedInstance.enabled =
GameKitHelper.sharedInstance.gameCenterEnabled }
}
}
}
// content you want your app to display goes here
struct ContentView: View {
#EnvironmentObject var gameCenter : GameKitHelper
#State private var isShowingGameCenter = false { didSet {
PopupControllerMessage
.GameCenter
.postNotification() }}
var body: some View {
VStack {
if self.gameCenter.enabled
{
Button(action:{ self.isShowingGameCenter.toggle()})
{ Text(
"Press to show leaderboards and achievements")}
}
// The authentication popup will appear when you first enter
// the view
}.onAppear() {GameKitHelper.sharedInstance
.authenticateLocalPlayer()}
}
}
import GameKit
import UIKit
// Messages sent using the Notification Center to trigger
// Game Center's Popup screen
public enum PopupControllerMessage : String
{
case PresentAuthentication = "PresentAuthenticationViewController"
case GameCenter = "GameCenterViewController"
}
extension PopupControllerMessage
{
public func postNotification() {
NotificationCenter.default.post(
name: Notification.Name(rawValue: self.rawValue),
object: self)
}
public func addHandlerForNotification(_ observer: Any,
handler: Selector) {
NotificationCenter.default .
addObserver(observer, selector: handler, name:
NSNotification.Name(rawValue: self.rawValue), object: nil)
}
}
// based on code from raywenderlich.com
// helper class to make interacting with the Game Center easier
open class GameKitHelper: NSObject, ObservableObject, GKGameCenterControllerDelegate {
public var authenticationViewController: UIViewController?
public var lastError: Error?
private static let _singleton = GameKitHelper()
public class var sharedInstance: GameKitHelper {
return GameKitHelper._singleton
}
private override init() {
super.init()
}
#Published public var enabled :Bool = false
public var gameCenterEnabled : Bool {
return GKLocalPlayer.local.isAuthenticated }
public func authenticateLocalPlayer () {
let localPlayer = GKLocalPlayer.local
localPlayer.authenticateHandler = {(viewController, error) in
self.lastError = error as NSError?
self.enabled = GKLocalPlayer.local.isAuthenticated
if viewController != nil {
self.authenticationViewController = viewController
PopupControllerMessage
.PresentAuthentication
.postNotification()
}
}
}
public var gameCenterViewController : GKGameCenterViewController? { get {
guard gameCenterEnabled else {
print("Local player is not authenticated")
return nil }
let gameCenterViewController = GKGameCenterViewController()
gameCenterViewController.gameCenterDelegate = self
gameCenterViewController.viewState = .achievements
return gameCenterViewController
}}
open func gameCenterViewControllerDidFinish(_
gameCenterViewController: GKGameCenterViewController) {
gameCenterViewController.dismiss(
animated: true, completion: nil)
}
}
Update: For SwiftUI 2.0 and iOS 14 the code is lot easier
import GameKit
enum Authenticate
{
static func user() {
let localPlayer = GKLocalPlayer.local
localPlayer.authenticateHandler = { _, error in
guard error == nil else {
print(error?.localizedDescription ?? "")
return
}
GKAccessPoint.shared.location = .topLeading
GKAccessPoint.shared.isActive =
localPlayer.isAuthenticated
}
}
}
import SwiftUI
// content you want your app to display goes here
struct ContentView: View {
var body: some View {
Text( "Start Game")
// The authentication popup will appear when you first enter
// the view
}.onAppear() { Authenticate.user()}
}
}
EDIT 2023: as mentioned in comments, GKScore is now deprecated. I don't have an updated solution to present.
Partial answer for you here. I'm able to download leaderboard scores and display them in a SwiftUI list provided the device (or simulator) is logged into iCloud and has GameCenter already enabled in settings. I have not attempted to make a gameCenter authentication view controller appear if that is not the case.
Thank you for the code in your question. I used your GameCenterManager() but put it in my AppDelegate:
let gameCenter = GameCenterManager()
Below is my ShowRankings.swift SwiftUI View. I'm able to successfully authenticate and get the scores. But I still have "anomalies". The first time I run this (in simulator) I get the expected "User is not logged into Game Center" error indicating the ViewController in your GameCenterManager is not nil (I never even attempt to display it). But then I'm able to successfully get the scores and display them in a list.
import SwiftUI
import GameKit
struct ShowRankings: View {
let appDelegate = UIApplication.shared.delegate as! AppDelegate
let leaderBoard = GKLeaderboard()
#State var scores: [GKScore] = []
var body: some View {
VStack {
Button(action: {
self.updateLeader()
}) {
Text("Refresh leaderboard")
}
List(scores, id: \.self) { score in
Text("\(score.player.alias) \(score.value)")
}
}.onAppear() {
self.appDelegate.gameCenter.authenticateLocalPlayer()
self.updateLeader()
}
}
func updateLeader() {
let leaderBoard: GKLeaderboard = GKLeaderboard()
leaderBoard.identifier = "YOUR_LEADERBOARD_ID_HERE"
leaderBoard.timeScope = .allTime
leaderBoard.loadScores { (scores, error) in
if let error = error {
debugPrint("leaderboard loadScores error \(error)")
} else {
guard let scores = scores else { return }
self.scores = scores
}
}
}
}
An alternative solution is to create a UIViewControllerRepresentable for GameCenter which takes a leaderboard ID to open. This makes it simple to open a specific leader board.
public struct GameCenterView: UIViewControllerRepresentable {
let viewController: GKGameCenterViewController
public init(leaderboardID : String?) {
if leaderboardID != nil {
self.viewController = GKGameCenterViewController(leaderboardID: leaderboardID!, playerScope: GKLeaderboard.PlayerScope.global, timeScope: GKLeaderboard.TimeScope.allTime)
}
else{
self.viewController = GKGameCenterViewController(state: GKGameCenterViewControllerState.leaderboards)
}
}
public func makeUIViewController(context: Context) -> GKGameCenterViewController {
let gkVC = viewController
gkVC.gameCenterDelegate = context.coordinator
return gkVC
}
public func updateUIViewController(_ uiViewController: GKGameCenterViewController, context: Context) {
return
}
public func makeCoordinator() -> GKCoordinator {
return GKCoordinator(self)
}
}
public class GKCoordinator: NSObject, GKGameCenterControllerDelegate {
var view: GameCenterView
init(_ gkView: GameCenterView) {
self.view = gkView
}
public func gameCenterViewControllerDidFinish(_ gameCenterViewController: GKGameCenterViewController) {
gameCenterViewController.dismiss(animated: true, completion: nil)
}
}
To use just add the below wherever it is needed to display a leaderboard.
GameCenterView(leaderboardID: "leaderBoardID")

SwiftUI Wrapped SearchBar Can't Dismiss Keyboard

I am using a UIViewRepresentable to add a search bar to a SwiftUI project. The search bar
is for searching the main List - I have setup the search logic and it works fine,
however I have not been able to code the keyboard to disappear when the search is
cancelled. The Cancel button does not respond. If I click the textfield clearButton
the search is ended and the full list appears but the keyboard does not disappear.
If I uncomment the resignFirstResponder line in textDidChange, the behavior is as
expected, except that the keyboard disappears after every character.
Here's the search bar:
import Foundation
import SwiftUI
struct MySearchBar: UIViewRepresentable {
#Binding var sText: String
class Coordinator: NSObject, UISearchBarDelegate {
#Binding var sText: String
init(sText: Binding<String>) {
_sText = sText
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
sText = searchText
//this works for EVERY character
//searchBar.resignFirstResponder()
}
}
func makeCoordinator() -> MySearchBar.Coordinator {
return Coordinator(sText: $sText)
}
func makeUIView(context: UIViewRepresentableContext<MySearchBar>) -> UISearchBar {
let searchBar = UISearchBar(frame: .zero)
searchBar.delegate = context.coordinator
searchBar.showsCancelButton = true
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<MySearchBar>) {
uiView.text = sText
}
func searchBarCancelButtonClicked(searchBar: UISearchBar) {
//this does not work
searchBar.text = ""
//none of these work
searchBar.resignFirstResponder()
searchBar.showsCancelButton = false
searchBar.endEditing(true)
}
}
And I load a MySearchBar in SwiftUI in the List.
var body: some View {
NavigationView {
List {
MySearchBar(sText: $searchTerm)
if !searchTerm.isEmpty {
ForEach(Patient.filterForSearchPatients(searchText: searchTerm)) { patient in
NavigationLink(destination: EditPatient(patient: patient, photoStore: self.photoStore, myTextViews: MyTextViews())) {
HStack(spacing: 30) {
//and the rest of the application
Xcode Version 11.2 beta 2 (11B44). SwiftUI. I have tested in the simulator and on a
device. Any guidance would be appreciated.
The reason searchBarCancelButtonClicked is not being called is because it is in MySearchBar but you have set the Coordinator as the search bars delegate. If you move the searchBarCancelButtonClicked func to the Coordinator, it will be called.
Here is what the coordinator should look like:
class Coordinator: NSObject, UISearchBarDelegate {
#Binding var sText: String
init(sText: Binding<String>) {
_sText = sText
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
sText = searchText
}
func searchBarCancelButtonClicked(searchBar: UISearchBar) {
searchBar.text = ""
searchBar.resignFirstResponder()
searchBar.showsCancelButton = false
searchBar.endEditing(true)
}
}
The searchBarCancelButtonClicked call will be passed to the UISearchBars delegate, which is set to Coordinator, so put func searchBarCancelButtonClicked there instead.
Also, when you are clearing the text you shouldnt set searchBar.text = "", which is setting the instance of UISearchBars text property directly, but instead clear your Coordinators property text. In that way your SwiftUI View will notice the change because of the #Binding property wrapper, and know that it´s time to update.
Here is the complete code for `MySearchBar´:
import UIKit
import SwiftUI
struct MySearchBar : UIViewRepresentable {
#Binding var text : String
class Coordinator : NSObject, UISearchBarDelegate {
#Binding var text : String
init(text : Binding<String>) {
_text = text
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
text = searchText
searchBar.showsCancelButton = true
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
text = ""
searchBar.showsCancelButton = false
searchBar.endEditing(true)
}
}
func makeCoordinator() -> SearchBar.Coordinator {
return Coordinator(text: $text)
}
func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
let searchBar = UISearchBar(frame: .zero)
searchBar.delegate = context.coordinator
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
uiView.text = text
}
}

Disabling macOS focus ring in SwiftUI

Is it possible to disable the focus ring around a TextField in swiftUI for Mac?
I had that question as well, and after a couple hours of fiddling around, it seems like the answer is no. However, it is possible to wrap an NSTextField and get rid of the focus ring.
The following code has been tested in the latest release.
struct CustomTextField: NSViewRepresentable {
#Binding var text: String
init(text: Binding<String>) {
_text = text
}
func makeNSView(context: Context) -> NSTextField {
let textField = NSTextField(string: text)
textField.delegate = context.coordinator
textField.isBordered = false
textField.backgroundColor = nil
textField.focusRingType = .none
return textField
}
func updateNSView(_ nsView: NSTextField, context: Context) {
nsView.stringValue = text
}
func makeCoordinator() -> Coordinator {
Coordinator { self.text = $0 }
}
final class Coordinator: NSObject, NSTextFieldDelegate {
var setter: (String) -> Void
init(_ setter: #escaping (String) -> Void) {
self.setter = setter
}
func controlTextDidChange(_ obj: Notification) {
if let textField = obj.object as? NSTextField {
setter(textField.stringValue)
}
}
}
}
As stated in an answer by Asperi to a similar question here, it's not possible (yet) to turn off the focus ring for a specific field using SwiftUI; however, the following workaround will disable the focus ring for all NSTextField instances in the app:
extension NSTextField {
open override var focusRingType: NSFocusRingType {
get { .none }
set { }
}
}
Not ideal, but it does provide one option that doesn't require stepping too far outside of SwiftUI.

Is there any way to make a paged ScrollView in SwiftUI?

I've been looking through the docs with each beta but haven't seen a way to make a traditional paged ScrollView. I'm not familiar with AppKit so I am wondering if this doesn't exist in SwiftUI because it's primarily a UIKit construct. Anyway, does anyone have an example of this, or can anyone tell me it's definitely impossible so I can stop looking and roll my own?
You can now use a TabView and set the .tabViewStyle to PageTabViewStyle()
TabView {
View1()
View2()
View3()
}
.tabViewStyle(PageTabViewStyle())
As of Beta 3 there is no native SwiftUI API for paging. I've filed feedback and recommend you do the same. They changed the ScrollView API from Beta 2 to Beta 3 and I wouldn't be surprised to see a further update.
It is possible to wrap a UIScrollView in order to provide this functionality now. Unfortunately, you must wrap the UIScrollView in a UIViewController, which is further wrapped in UIViewControllerRepresentable in order to support SwiftUI content.
Gist here
class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = true
return v
}()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.scrollView)
self.pinEdges(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.pinEdges(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
}
func pinEdges(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
])
}
}
struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.hostingController.rootView = AnyView(self.content())
return vc
}
func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(self.content())
}
}
And then to use it:
var body: some View {
GeometryReader { proxy in
UIScrollViewWrapper {
VStack {
ForEach(0..<1000) { _ in
Text("Hello world")
}
}
.frame(width: proxy.size.width) // This ensures the content uses the available width, otherwise it will be pinned to the left
}
}
}
Apple's official tutorial covers this as an example. I find it easy to follow and suitable for my case. I really recommend you check this out and try to understand how to interface with UIKit. Since SwiftUI is so young, not every feature in UIKit would be covered at this moment. Interfacing with UIKit should address most if not all needs.
https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit
Not sure if this helps your question but for the time being while Apple is working on adding a Paging View in SwiftUI I've written a utility library that gives you a SwiftUI feel while using a UIPageViewController under the hood tucked away.
You can use it like this:
Pages {
Text("Page 1")
Text("Page 2")
Text("Page 3")
Text("Page 4")
}
Or if you have a list of models in your application you can use it like this:
struct Car {
var model: String
}
let cars = [Car(model: "Ford"), Car(model: "Ferrari")]
ModelPages(cars) { index, car in
Text("The \(index) car is a \(car.model)")
.padding(50)
.foregroundColor(.white)
.background(Color.blue)
.cornerRadius(10)
}
You can simply track state using .onAppear() to load your next page.
struct YourListView : View {
#ObservedObject var viewModel = YourViewModel()
let numPerPage = 50
var body: some View {
NavigationView {
List(viewModel.items) { item in
NavigationLink(destination: DetailView(item: item)) {
ItemRow(item: item)
.onAppear {
if self.shouldLoadNextPage(currentItem: item) {
self.viewModel.fetchItems(limitPerPage: self.numPerPage)
}
}
}
}
.navigationBarTitle(Text("Items"))
.onAppear {
guard self.viewModel.items.isEmpty else { return }
self.viewModel.fetchItems(limitPerPage: self.numPerPage)
}
}
}
private func shouldLoadNextPage(currentItem item: Item) -> Bool {
let currentIndex = self.viewModel.items.firstIndex(where: { $0.id == item.id } )
let lastIndex = self.viewModel.items.count - 1
let offset = 5 //Load next page when 5 from bottom, adjust to meet needs
return currentIndex == lastIndex - offset
}
}
class YourViewModel: ObservableObject {
#Published private(set) items = [Item]()
// add whatever tracking you need for your paged API like next/previous and count
private(set) var fetching = false
private(set) var next: String?
private(set) var count = 0
func fetchItems(limitPerPage: Int = 30, completion: (([Item]?) -> Void)? = nil) {
// Do your stuff here based on the API rules for paging like determining the URL etc...
if items.count == 0 || items.count < count {
let urlString = next ?? "https://somePagedAPI?limit=/(limitPerPage)"
fetchNextItems(url: urlString, completion: completion)
} else {
completion?(pokemon)
}
}
private func fetchNextItems(url: String, completion: (([Item]?) -> Void)?) {
guard !fetching else { return }
fetching = true
Networking.fetchItems(url: url) { [weak self] (result) in
DispatchQueue.main.async { [weak self] in
self?.fetching = false
switch result {
case .success(let response):
if let count = response.count {
self?.count = count
}
if let newItems = response.results {
self?.items += newItems
}
self?.next = response.next
case .failure(let error):
// Error state tracking not implemented but would go here...
os_log("Error fetching data: %#", error.localizedDescription)
}
}
}
}
}
Modify to fit whatever API you are calling and handle errors based on your app architecture.
Checkout SwiftUIPager. It's a pager built on top of SwiftUI native components:
If you would like to exploit the new PageTabViewStyle of TabView, but you need a vertical paged scroll view, you can make use of effect modifiers like .rotationEffect().
Using this method I wrote a library called VerticalTabView 🔝 that turns a TabView vertical just by changing your existing TabView to VTabView.
You can use such custom modifier:
struct ScrollViewPagingModifier: ViewModifier {
func body(content: Content) -> some View {
content
.onAppear {
UIScrollView.appearance().isPagingEnabled = true
}
.onDisappear {
UIScrollView.appearance().isPagingEnabled = false
}
}
}
extension ScrollView {
func isPagingEnabled() -> some View {
modifier(ScrollViewPagingModifier())
}
}
To simplify Lorenzos answer, you can basically add UIScrollView.appearance().isPagingEnabled = true to your scrollview as below:
VStack{
ScrollView(showsIndicators: false){
VStack(spacing: 0){ // to remove spacing between rows
ForEach(1..<10){ i in
ZStack{
Text(String(i))
Circle()
} .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
}
}
}.onAppear {
UIScrollView.appearance().isPagingEnabled = true
}
.onDisappear {
UIScrollView.appearance().isPagingEnabled = false
}
}

Resources