SwiftUI - How to disable sidebar from collapsing? - swift

Gif to understand easier
Is there any way to disable collapsibility of SidebarListStyle NavigationViews?

EDIT: This method still works as of late 2022, and has never stopped working on any version of macOS (up to latest Ventura 13.1). Not sure why there are answers here suggesting otherwise. If the Introspection library changes their API you may need to update your calls accordingly, but the gist of the solution is the same.
Using this SwiftUI Introspection library:
https://github.com/siteline/SwiftUI-Introspect
We can introspect the underlying NSSplitView by extending their functionality:
public func introspectSplitView(customize: #escaping (NSSplitView) -> ()) -> some View {
return introspect(selector: TargetViewSelector.ancestorOrSibling, customize: customize)
}
And then create a generic extension on View:
public extension View {
func preventSidebarCollapse() -> some View {
return introspectSplitView { splitView in
(splitView.delegate as? NSSplitViewController)?.splitViewItems.first?.canCollapse = false
}
}
}
Which can be used on our sidebar:
var body: some View {
(...)
MySidebar()
.preventSidebarCollapse()
}

The introspection library mentioned by Oskar is not working for MacOS.
Inspired by that, I figured out a solution for MacOS.
The rationality behind the solution is to use a subtle way to find out the parent view of a NavigationView which is a NSSplitViewController in the current window.
Below codes was tested on XCode 13.2 and macOS 12.1.
var body: some View {
Text("Replace with your sidebar view")
.onAppear {
guard let nsSplitView = findNSSplitVIew(view: NSApp.windows.first?.contentView), let controller = nsSplitView.delegate as? NSSplitViewController else {
return
}
controller.splitViewItems.first?.canCollapse = false
// set the width of your side bar here.
controller.splitViewItems.first?.minimumThickness = 150
controller.splitViewItems.first?.maximumThickness = 150
}
}
private func findNSSplitVIew(view: NSView?) -> NSSplitView? {
var queue = [NSView]()
if let root = view {
queue.append(root)
}
while !queue.isEmpty {
let current = queue.removeFirst()
if current is NSSplitView {
return current as? NSSplitView
}
for subview in current.subviews {
queue.append(subview)
}
}
return nil
}

While the method that Oskar used with the Introspect library no longer works, I did find another way of preventing the sidebar from collapsing using Introspect. First, you need to make an extension on View:
extension View {
public func introspectSplitView(customize: #escaping (NSSplitView) -> ()) -> some View {
return inject(AppKitIntrospectionView(
selector: { introspectionView in
guard let viewHost = Introspect.findViewHost(from: introspectionView) else {
return nil
}
return Introspect.findAncestorOrAncestorChild(ofType: NSSplitView.self, from: viewHost)
},
customize: customize
))
}
}
Then do the following:
NavigationView {
SidebarView()
.introspectSplitView { controller in
(controller.delegate as? NSSplitViewController)?.splitViewItems.first?.canCollapse = false
}
Text("Main View")
}
This being said, we don't know how long this will actually work for. Apple could change how NavigationView works and this method may stop working in the future.

Related

SwiftUI: calling objectWillChange.send() not updating child view

I have a rather complicated set of views nested in views. When I trigger a button action, I pass along an optional block through my viewModel class which calls objectWillChange.send() on that viewModel and I know that it's being triggered because the other parts of my view are updating. One of the child views (which is observing that viewModel changes) doesn't update until I click on part of it (which changes viewModel.selectedIndex and triggers redraw so I know it's listening for published changes).
Why isn't the update triggering the child view (in this case PurchaseItemGrid) to redraw itself?
Here's where I setup the call to update...
struct RightSideView: View {
#ObservedObject var viewModel: TrenchesPurchases
var body: some View {
VStack {
...
PurchaseItemGrid(viewModel: viewModel) // <-- View not updating
Button {
viewModel.purchaseAction() {
viewModel.objectWillChange.send() // <-- Triggers redraw, reaches breakpoint here
}
} label: {
...
}
...
}
}
}
Here's where the optional is called (and I've not only visually confirmed this is happening as other parts of the view redraw, it also hits breakpoint here)...
class TrenchesPurchases: ObservableObject, CanPushCurrency {
// MARK: - Properties
#Published private var model = Purchases()
// MARK: - Properties: Computed
var selectedIndex: Int {
get { return model.selectedIndex }
set { model.selectedIndex = newValue }
}
var purchaseAction: BlockWithBlock {
{ complete in
...
complete?()
}
}
...
}
And here's the view that's not updating as expected...
struct PurchaseItemGrid: View {
#ObservedObject var viewModel: TrenchesPurchases
var body: some View {
VStack {
itemRow(indices: 0...3)
...
}
...
}
#ViewBuilder
func itemRow(indices range: ClosedRange<Int>) -> some View {
HStack {
ForEach(viewModel.purchaseItems[range], id: \.id) { item in
PurchaseItemView(item: item,
borderColor: viewModel.selectedIndex == item.id ? .green : Color(Colors.oliveGreen))
.onTapGesture { viewModel.selectedIndex = item.id }
}
}
}
}
Here's the code workingdog asked for...
struct Purchases {
// MARK: - Properties
var selectedIndex = 15
let items: [PurchaseItem] = buildCollectionOfItems()
// MARK: - Functions
// MARK: - Functions: Static
// TODO: Define Comments
static func buildCollectionOfItems() -> [PurchaseItem] {
return row0() + row1() + row2() + row3()
}
static func row0() -> [PurchaseItem] {
var items = [PurchaseItem]()
let grenade = Ammo(ammo: .grenade)
items.append(grenade)
let bullets = Ammo(ammo: .bullets)
items.append(bullets)
let infiniteBullets = Unlock(mode: .defense)
items.append(infiniteBullets)
let unlimitedInfantry = Unlock(mode: .offense)
items.append(unlimitedInfantry)
return items
}
static func row1() -> [PurchaseItem] {
var items = [PurchaseItem]()
for unit in UnitType.allCases {
let item = Unit(unit: unit)
items.append(item)
}
return items
}
static func row2() -> [PurchaseItem] {
var items = [PurchaseItem]()
let brits = NationItem(nation: .brits)
items.append(brits)
let turks = NationItem(nation: .turks)
items.append(turks)
let usa = NationItem(nation: .usa)
items.append(usa)
let insane = DifficultyItem(difficulty: .insane)
items.append(insane)
return items
}
static func row3() -> [PurchaseItem] {
var items = [PurchaseItem]()
let offenseLootBox = Random(mode: .offense)
items.append(offenseLootBox)
let defenseLootBox = Random(mode: .defense)
items.append(defenseLootBox)
let currency = Currency(isCheckin: false)
items.append(currency)
let checkIn = Currency(isCheckin: true)
items.append(checkIn)
return items
}
}
The issue I had was that the PurchaseItemGrid was noticing the observed item being published, but the change I was trying to trigger was in the PurchaseItemView which did not have an observed object.
I assumed that when the PurchaseItemGrid observed the change and was redrawn, the itemRow method would redraw a new collection of PurchaseItemView's that would then have their image updated to match the new state.
This was further compounded because the onTapGesture was triggering a redraw of the PurchaseItemView, and to be honest I'm still not sure how the PurchaseItemGrid could redraw itself while still using the same PurchaseItemView's in it's body; but it may have to do with how #ViewBuilder works and because the views were created in an entirely separate method.
So, long story short: make sure each view you want to update has some form of observer, don't rely on the parent's redraw to create new child views.

SwiftUI TextField loses focus when styled

I'm trying to apply some validation to a TextField to add a red border around the field when the content is invalid (in this case, I'm validating that the content is a positive number that is less than the specified maxLength).
The validation works fine and the border is applied when the value is out of range. However, when the border is applied to the TextField, the TextField loses focus in the UI (and also loses focus when the border is removed).
Here is a snippet of my code (I've included some extensions I'm using, but I don't think those are relevant to the issue)
import Foundation
import SwiftUI
import Combine
struct MyView : View {
#Binding var value: Int
var label: String
var maxLength: Int
#State private var valid: Bool = true
var body: some View {
TextField(label, value: $value, format: .number)
.textFieldStyle(RoundedBorderTextFieldStyle())
.fixedSize()
.multilineTextAlignment(.trailing)
.onReceive(Just(value), perform: validate)
.if(!valid, transform: { $0.border(.red)})
}
func validate(val: Int) {
let newVal = val.clamped(to: 0...maxLength)
if newVal != val {
valid = false
} else {
valid = true
}
}
}
extension View {
#ViewBuilder
func `if`<Transform: View>(_ condition: Bool, transform: (Self) -> Transform) -> some View {
if condition { transform(self) }
else { self }
}
}
extension Comparable {
func clamped(to limits: ClosedRange<Self>) -> Self {
return min(max(self, limits.lowerBound), limits.upperBound)
}
}
Is there any way to preserve focus on the TextField when styles are conditionally applied to it?
Or am I approaching validation wrong altogether? Is there a better way to check fields and apply conditional styling?
Because of the way your if modifier is structured, SwiftUI is unable to see that the underlying View is the same in the two conditions. For more detail on this, I'd suggest you watch Demystifying SwiftUI from WWDC 2021.
One solution is the simplify your border modifier into the following:
.border(valid ? .clear : .red)
This way, SwiftUI can still tell that this is the same underlying View.

How set Position of window on the Desktop in SwiftUI?

How to set window coordinates in SwiftUI on MacOS Desktop? For example, should the window appear always in the center or always in the upper right corner?
Here is my version, however, I shift the code and close it, when I open it, it appears first in the old place, and then jumps to a new place.
import SwiftUI
let WIDTH: CGFloat = 400
let HEIGTH: CGFloat = 200
#main
struct ForVSCode_MacOSApp: App {
#State var window : NSWindow?
var body: some Scene {
WindowGroup {
ContentView(win: $window)
}
}
}
struct WindowAccessor: NSViewRepresentable{
#Binding var window: NSWindow?
func makeNSView(context: Context) -> some NSView {
let view = NSView()
let width = (NSScreen.main?.frame.width)!
let heigth = (NSScreen.main?.frame.height)!
let resWidth: CGFloat = (width / 2) - (WIDTH / 2)
let resHeigt: CGFloat = (heigth / 2) - (HEIGTH / 2)
DispatchQueue.main.async {
self.window = view.window
self.window?.setFrameOrigin(NSPoint(x: resWidth, y: resHeigt))
self.window?.setFrameAutosaveName("mainWindow")
self.window?.isReleasedWhenClosed = false
self.window?.makeKeyAndOrderFront(nil)
}
return view
}
func updateNSView(_ nsView: NSViewType, context: Context) {
}
}
and ContentView
import SwiftUI
struct ContentView: View {
#Binding var win: NSWindow?
var body: some View {
VStack {
Text("it finally works!")
}
.font(.largeTitle)
.frame(width: WIDTH, height: HEIGTH, alignment: .center)
.background(WindowAccessor(window: $win))
}
}
struct ContentView_Previews: PreviewProvider {
#Binding var win: NSWindow?
static var previews: some View {
ContentView(win: .constant(NSWindow()))
.frame(width: 250, height: 150, alignment: .center)
}
}
I do have the same issue in one of my projects and thought I will investigate a bit deeper and I found two approaches to control the window position.
So my first approach to influence the window position is by pre-defining the windows last position on screen.
Indirect control: Frame autosave name
When the first window of an app is opened, macOS will try to restore the last window position when it was last closed. To distinguish the different windows, each window has its own frameAutosaveName.
The windows frame is persisted automatically in a text format in the apps preferences (UserDefaults.standard) with the key derived from the frameAutosaveName: "NSWindow Frame <frameAutosaveName>" (see docs for saveFrame).
If you do not specify an ID in your WindowGroup, SwiftUI will derive the autosave name from your main views class name. The first three windows will have the following autosave names:
<ModuleName>.ContentView-1-AppWindow-1
<ModuleName>.ContentView-1-AppWindow-2
<ModuleName>.ContentView-1-AppWindow-3
By setting an ID for example WindowGroup(id: "main"), the following autosave names are used (again for the first three windows):
main-AppWindow-1
main-AppWindow-2
main-AppWindow-3
When you check in your apps preferences directory (where UserDefaults.standard is stored), you will see in the plist one entry:
NSWindow Frame main-AppWindow-1 1304 545 400 228 0 0 3008 1228
There are a lot of numbers to digest. The first 4 integers describe the windows frame (origin and size), the next 4 integers describe the screens frame.
There are a few things to keep in mind when manually setting those value:
macOS coordinate system has it origin (0,0) in the bottom left corner.
the windows height includes the window title bar (28px on macOS Monterey but may be different on other versions)
the screens height excludes the title bar
I don't have documentation on this format and used trial and error to gain knowledge about it...
So to fake the initial position in the center of the screen I used the following function which I run in the apps (or the ContentView) initializer. But keep in mind: with this method only the first window will be centered. All the following windows are going to be put down and right of the previous window.
func fakeWindowPositionPreferences() {
let main = NSScreen.main!
let screenWidth = main.frame.width
let screenHeightWithoutMenuBar = main.frame.height - 25 // menu bar
let visibleFrame = main.visibleFrame
let contentWidth = WIDTH
let contentHeight = HEIGHT + 28 // window title bar
let windowX = visibleFrame.midX - contentWidth/2
let windowY = visibleFrame.midY - contentHeight/2
let newFramePreference = "\(Int(windowX)) \(Int(windowY)) \(Int(contentWidth)) \(Int(contentHeight)) 0 0 \(Int(screenWidth)) \(Int(screenHeightWithoutMenuBar))"
UserDefaults.standard.set(newFramePreference, forKey: "NSWindow Frame main-AppWindow-1")
}
My second approach is by directly manipulating the underlying NSWindow similar to your WindowAccessor.
Direct control: Manipulating NSWindow
Your implementation of WindowAccessor has a specific flaw: Your block which is reading view.window to extract the NSWindow instance is run asynchronously: some time in the future (due to DispatchQueue.main.async).
This is why the window appears on screen on the SwiftUI configured position, then disappears again to finally move to your desired location. You need more control, which involves first monitoring the NSView to get informed as soon as possible when the window property is set and then monitoring the NSWindow instance to get to know when the view is becoming visible.
I'm using the following implementation of WindowAccessor. It takes a onChange callback closure which is called whenever window is changing. First it starts monitoring the NSViews window property to get informed when the view is added to a window. When this happened, it starts listening for NSWindow.willCloseNotification notifications to detect when the window is closing. At this point it will stop any monitoring to avoid leaking memory.
import SwiftUI
import Combine
struct WindowAccessor: NSViewRepresentable {
let onChange: (NSWindow?) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView()
context.coordinator.monitorView(view)
return view
}
func updateNSView(_ view: NSView, context: Context) {
}
func makeCoordinator() -> WindowMonitor {
WindowMonitor(onChange)
}
class WindowMonitor: NSObject {
private var cancellables = Set<AnyCancellable>()
private var onChange: (NSWindow?) -> Void
init(_ onChange: #escaping (NSWindow?) -> Void) {
self.onChange = onChange
}
/// This function uses KVO to observe the `window` property of `view` and calls `onChange()`
func monitorView(_ view: NSView) {
view.publisher(for: \.window)
.removeDuplicates()
.dropFirst()
.sink { [weak self] newWindow in
guard let self = self else { return }
self.onChange(newWindow)
if let newWindow = newWindow {
self.monitorClosing(of: newWindow)
}
}
.store(in: &cancellables)
}
/// This function uses notifications to track closing of `window`
private func monitorClosing(of window: NSWindow) {
NotificationCenter.default
.publisher(for: NSWindow.willCloseNotification, object: window)
.sink { [weak self] notification in
guard let self = self else { return }
self.onChange(nil)
self.cancellables.removeAll()
}
.store(in: &cancellables)
}
}
}
This implementation can then be used to get a handle to NSWindow as soon as possible. The issue we still face: we don't have full control of the window. We are just monitoring what happens and can interact with the NSWindow instance. This means: we can set the position, but we don't know exactly at which instant this should happen. E.g. setting the windows frame directly after the view has been added to the window, will have no impact as SwiftUI is first doing layout calculations to decide afterwards where it will place the window.
After some fiddling around, I started tracking the NSWindow.isVisible property. This allows me to set the position whenever the window becomes visible. Using above WindowAccessor my ContentView implementation looks as follows:
import SwiftUI
import Combine
let WIDTH: CGFloat = 400
let HEIGHT: CGFloat = 200
struct ContentView: View {
#State var window : NSWindow?
#State private var cancellables = Set<AnyCancellable>()
var body: some View {
VStack {
Text("it finally works!")
.font(.largeTitle)
Text(window?.frameAutosaveName ?? "-")
}
.frame(width: WIDTH, height: HEIGHT, alignment: .center)
.background(WindowAccessor { newWindow in
if let newWindow = newWindow {
monitorVisibility(window: newWindow)
} else {
// window closed: release all references
self.window = nil
self.cancellables.removeAll()
}
})
}
private func monitorVisibility(window: NSWindow) {
window.publisher(for: \.isVisible)
.dropFirst() // we know: the first value is not interesting
.sink(receiveValue: { isVisible in
if isVisible {
self.window = window
placeWindow(window)
}
})
.store(in: &cancellables)
}
private func placeWindow(_ window: NSWindow) {
let main = NSScreen.main!
let visibleFrame = main.visibleFrame
let windowSize = window.frame.size
let windowX = visibleFrame.midX - windowSize.width/2
let windowY = visibleFrame.midY - windowSize.height/2
let desiredOrigin = CGPoint(x: windowX, y: windowY)
window.setFrameOrigin(desiredOrigin)
}
}
I hope this solution helps others who want to get more control to the window in SwiftUI.

SwiftUI: using view modifiers between different iOS versions without #available

I use the following code snippet (in Xcode 13 Beta 5 and deployment target set to 14.0) to apply view modifiers conditionally according to iOS version:
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.modifyFor(iOS14: {
$0.onAppear {
//do some stuff
}
}, iOS15: {
$0.task { //<---- Error: 'task(priority:_:)' is only available in iOS 15.0 or newer
//do some stuff
}
})
}
}
struct CompatibleView<Input: View,
Output14: View,
Output15: View>: View {
var content: Input
var iOS14modifier: ((Input) -> Output14)?
var iOS15modifier: ((Input) -> Output15)?
#ViewBuilder var body: some View {
if #available(iOS 15, *) {
if let modifier = iOS15modifier {
modifier(content)
}
else { content }
}
else {
if let modifier = iOS14modifier {
modifier(content)
}
else { content }
}
}
}
extension View {
func modifyFor<T: View, U: View>(iOS14: ((Self) -> T)? = nil,
iOS15: ((Self) -> U)? = nil) -> some View {
CompatibleView(content: self,
iOS14modifier: iOS14,
iOS15modifier: iOS15)
}
}
this code works great as long as I don't use iOS 15's view modifiers, but if I want to use any of those modifiers (like Task for ex.) then I need to use the #available directive which's an option I don't wanna opt in, because my codebase is large, there are many parts that should adopt the new iOS 15 modifiers and by using #available everywhere in the code will make it looks like a dish of Lasagna.
how to make this piece of code compiles in a clean way and without using the #available check ?
The best solution for so far I've figured out is to add simple modify extension function for view and use that.
It's useful if availability check for modifier is needed only in one place.
If needed in more than one place, then create new modifier function.
public extension View {
func modify<Content>(#ViewBuilder _ transform: (Self) -> Content) -> Content {
transform(self)
}
}
And using it would be:
Text("Good")
.modify {
if #available(iOS 15.0, *) {
$0.badge(2)
} else {
// Fallback on earlier versions
}
}
EDIT:
#ViewBuilder
func modify<Content: View>(#ViewBuilder _ transform: (Self) -> Content?) -> some View {
if let view = transform(self), !(view is EmptyView) {
view
} else {
self
}
}
This allows us not to define fallback if not required and the view will stay untouchable.
Text("Good")
.modify {
if #available(iOS 15.0, *) {
$0.badge(2)
}
}
There is no way to do this without 'if #available', but there is a way to structure it in a somewhat clean way.
Define your own View Modifier on a wrapper View:
struct Backport<Content> {
let content: Content
}
extension View {
var backport: Backport<Self> { Backport(content: self) }
}
extension Backport where Content: View {
#ViewBuilder func badge(_ count: Int) -> some View {
if #available(iOS 15, *) {
content.badge(count)
} else {
content
}
}
}
You can then use these like this:
TabView {
Color.yellow
.tabItem {
Label("Example", systemImage: "hand.raised")
}
.backport.badge(5)
}
Blog post about it:
Using iOS-15-only View modifiers in older iOS versions
You can create a simple extension on View with #ViewBuilder
fileprivate extension View {
#ViewBuilder
var tabBarTintColor: some View {
if #available(iOS 16, *) {
self.tint(.red)
} else {
self.accentColor(.red)
}
}
}
To use it just have it chained with your existing view
TabView()
.tabBarTintColor
There is no point because even if you did back-port a modifier named task (which is how this problem is normally solved) you won’t be able to use all the magic of async/await inside which is what it was designed for. If you have a good reason for not targeting iOS 15 (I don’t know any good ones) then just continue to use onAppear as normal and either standard dispatch queue async or Combine in an #StateObject.
There is no logical use case for that modifier for the issue you are trying to solve! You have no idea, how many times your app would check your condition about availability of iOS15 in each render! Maybe 1000 of times! Insane number of control which is totally bad idea! instead use deferent Views for each scenarios like this, it would checked just one time:
WindowGroup {
if #available(iOS 15, *) {
ContentView_For_iOS15()
}
else {
ContentView_For_No_iOS15()
}
}

Sharing Screenshot of SwiftUI view causes crash

I am grabbing a screenshot of a sub-view in my SwiftUI View to immediately pass to a share sheet in order to share the image.
The view is of a set of questions from a text array rendered as a stack of cards. I am trying to get a screenshot of the question and make it share-able along with a link to the app (testing with a link to angry birds).
I have been able to capture the screenshot using basically Asperi's answer to the below question:
How do I render a SwiftUI View that is not at the root hierarchy as a UIImage?
My share sheet launches, and I've been able to use the "Copy" feature to copy the image, so I know it's actually getting a screenshot, but whenever I click "Message" to send it to someone, or if I just leave the share sheet open, the app crashes.
The message says it's a memory issue, but doesn't give much description of the problem. Is there a good way to troubleshoot this sort of thing? I assume it must be something with how the screenshot is being saved in this case.
Here are my extensions of View and UIView to render the image:
extension UIView {
func asImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
}
}
extension View {
func asImage() -> UIImage {
let controller = UIHostingController(rootView: self)
// locate far out of screen
controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1)
UIApplication.shared.windows.first!.rootViewController?.view.addSubview(controller.view)
let size = controller.sizeThatFits(in: UIScreen.main.bounds.size)
controller.view.bounds = CGRect(origin: .zero, size: size)
controller.view.sizeToFit()
controller.view.backgroundColor = .clear
let image = controller.view.asImage()
controller.view.removeFromSuperview()
return image
}
}
Here's an abbreviated version of my view - the button is about halfway down, and should call the private function at the bottom that renders the image from the View/UIView extensions, and sets the "questionScreenShot" variable to the rendered image, which is then presented in the share sheet.
struct TopicPage: View {
var currentTopic: Topic
#State private var currentQuestions: [String]
#State private var showShareSheet = false
#State var questionScreenShot: UIImage? = nil
var body: some View {
GeometryReader { geometry in
Button(action: {
self.questionScreenShot = render()
if self.questionScreenShot != nil {
self.showShareSheet = true
} else {
print("Did not set screenshot")
}
}) {
Text("Share Question").bold()
}
.sheet(isPresented: $showShareSheet) {
ShareSheet(activityItems: [questionScreenShot!])
}
}
}
private func render() -> UIImage {
QuestionBox(currentQuestion: self.currentQuestions[0]).asImage()
}
}
I've found a solution that seems to be working here. I start the variable where the questionScreenShot gets stored as nil to start:
#State var questionScreenShot: UIImage? = nil
Then I just make sure to set it to 'render' when the view appears, which means it loads the UIImage so if the user clicks "Share Question" it will be ready to be loaded (I think there was an issue earlier where the UIImage wasn't getting loaded in time once the sharing was done).
It also sets that variable back to nil on disappear.
.onAppear {
self.currentQuestions = currentTopic.questions.shuffled()
self.featuredQuestion = currentQuestions.last!
self.questionScreenShot = render()
}
.onDisappear {
self.questionScreenShot = nil
self.featuredQuestion = nil
}