Rendering bug? when using transparent NSWindow with SwiftUI for MacOS app - macos

I was developing a macOS app using SwiftUI (latest version from XCode 14.1) with the following App setup
import SwiftUI
#main
struct DemoApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
Window("demo", id: "demo") {
ContentView()
}.windowStyle(.hiddenTitleBar)
}
}
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
#Published var active: Bool = true
var mainWindow: NSWindow? {
for w in NSApplication.shared.windows {
if w.title == "demo" {
return w
}
}
return nil
}
private func setupMainWindow() {
}
#MainActor func applicationDidFinishLaunching(_: Notification) {
guard let w = mainWindow else {
print("nothing to setup")
return
}
w.level = .floating
w.backgroundColor = NSColor.clear
w.isOpaque = false
w.standardWindowButton(.zoomButton)?.isHidden = true
w.standardWindowButton(.closeButton)?.isHidden = true
w.standardWindowButton(.miniaturizeButton)?.isHidden = true
}
}
The content view is defined as following
import SwiftUI
struct ContentView: View {
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
#State var counter = 0
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world! \(counter)").font(.system(size: 20).monospaced())
}
.padding().onReceive(timer) { _ in
counter += 1
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Basically what this app does is render a transparent background window with some content on it. The content has a static image and a text that updates every second (with a counter).
The initial rendering of the window looks ok, but once the content starts update, there are two duplicated artifacts being rendered: one from the first rendering result and second from the latest rendering result. The second is overlapped on top the first one.
Here is a screenshot of the window rendering on top of a dark colored background (another window):
Here is a screenshot of the window rendering on top of a white colored background (another window):
It seems to me when the window initially launches, because it's transparent, somehow the OS or framework is adding some outline to the rendering content, as you can see from the "Hello world 0" string, there is a outline there. Maybe the framework is trying to do something to differentiate the window content from other content below it?
When the window starts updating the content, the new content will be re-drawn, but the initial captured outline is still there, leaving this weird artifact that doesn't match the current rendered content.
I've tried to disable the transparent window (with solid background or background with non-zero alpha). That will completely eliminate the issue. The added outline is only seen when the window is completely transparent. As far as I can tell, it's related to the transparent window background and the outline added to the content.
Update: using hasShadow=false won't remove the border completely (the top is still visible on black background).

I'm configuring the window in a slightly different way, without the need for an AppDelegate, but I'm seeing the same thing. With a backgroundColor of .clear, not only is the rendering artefact apparent, also the window has no border.
Using a NSColor.white.withAlphaComponent(0.00001) as the backgroundColor eliminates the rendering issue, and also shows the border correctly.
struct Mac_SwiftUI_testApp: App {
var body: some Scene {
Window("demo", id: "demo") {
ContentView()
.task {
NSApplication.shared.windows.forEach { window in
guard window.identifier?.rawValue == "demo" else { return }
window.standardWindowButton(.zoomButton)?.isHidden = true
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.backgroundColor = NSColor.white.withAlphaComponent(0.00001)
window.isOpaque = false
window.level = .floating
}
}
}
.windowStyle(.hiddenTitleBar)
}
}
With backgroundColor = .clear
With backgroundColor = NSColor.white.withAlphaComponent(0.00001)
After trying a few things out, I found that setting
window.hasShadow = false
will fix your problem. It also removes the top "line" on the clear window

Related

SwiftUI Tooltip on hover

SwiftUI provides the .help() modifier but it is too small, cannot be customised and takes too long to appear to actually serve its intended purpose. I would like to create a tooltip that appears immediately on hover and is larger, similar to the one that appears on hovering on an icon in the Dock.
Something like this:
Is this possible to create from SwiftUI itself? I've tried using a popover but it prevents hover events from propagating once its open, so I can't make it close when the mouse moves away.
Solution #1: Check for .onHover(...)
Use the .onHover(perform:) view modifier to toggle a #State property to keep track of whether your tooltip should be displayed:
#State var itemHovered: Bool = false
var body: some View {
content
.onHover { hover in
itemHovered = hover
}
.overlay(
Group {
if itemHovered {
Text("This is a tooltip")
.background(Color.white)
.foregroundColor(.black)
.offset(y: -50.0)
}
}
)
}
Solution #2: Make a Tooltip Wrapper View
Create a view wrapper that creates a tooltip view automatically:
struct TooltipWrapper<Content>: View where Content: View {
#ViewBuilder var content: Content
var hover: Binding<Bool>
var text: String
var body: some View {
content
.onHover { hover.wrappedValue = $0 }
.overlay(
Group {
if hover.wrappedValue {
Text("This is a tooltip")
.background(Color.white)
.foregroundColor(.black)
.offset(y: -50.0)
}
}
)
}
}
Then you can call with
#State var hover: Bool = false
var body: some View {
TooltipWrapper(hover: $hover, text: "This is a tooltip") {
Image(systemName: "arrow.right")
Text("Hover over me!")
}
}
From this point, you can customize the hover tooltip wrapper to your liking.
Solution #3: Use my Swift Package
I wrote a 📦 Swift Package that makes SwiftUI a little easier for personal use, and it includes a tooltip view modifier that boils the solution down to:
import ShinySwiftUI
#State var showTooltip: Bool = false
var body: some View {
MyView()
.withTooltip(present: $showTooltip) {
Text("This is a tooltip!")
}
}
Notice you can provide your own custom views in the tooltip modifier above, like Image or VStack. Alternatively, you could use HoverView to get a stateful hover variable to use solely within your view:
HoverView { hover in
Rectangle()
.foregroundColor(hover ? .red : .blue)
.overlay(
Group {
if hover { ... }
}
)
}

Designing a view with an inspector

I'm building a new version of an app in SwiftUI and have run into a conundrum.
The scenario is that I have a canvas with multiple objects on it, and I would like to have an inspector view that shows details of the object currently under the pointer. The problem is that my initial implementation makes the whole canvas redraw far too many times.
What I have is a #State variable which the inspector displays, so the inspector view needs to see this state. The object view needs to be able to write to the state variable, so that means that when the pointer comes over an object, it changes the state variable, which then means that both the object (really, the canvas) and the inspector get marked as needing to be redrawn.
What would be nice would be for the object view to be able to write to the state that the inspector needs without invalidating its own view, but I can't work out how to do this in SwiftUI. In the old paradigm, I'd send a message to the inspector, but that doesn't seem to be the way in the declarative paradigm of SwiftUI. I suspect that some sort of observable object may be a solution, but I haven't worked out how to do that.
Related is the behaviour of .onHover, which I am using to generate the mouse entered/mouse exited events like so:
.onHover { entered in
inspectorText = entered ? self.descriptionText : ""
}
I seem to get multiple enter/exit events, presumably due to the redrawing, which slows things to a crawl. Is there a better method than using .onHover?
I'm pretty sure that there is a clever way (maybe with Equatable views...?) for preventing re-evaluation of the main body, but for what is worth here is a possible solution using a singleton:
import SwiftUI
class HoverState: ObservableObject {
static let shared: HoverState = HoverState()
#Published var currentItemText: String = ""
}
struct InspectorView: View {
#ObservedObject var hoverState: HoverState = HoverState.shared
var body: some View {
Text(hoverState.currentItemText)
}
}
struct ContentView: View {
var body: some View {
HStack {
VStack {
Color.red
.onHover { _ in HoverState.shared.currentItemText = "Red" }
Color.green
.onHover { _ in HoverState.shared.currentItemText = "Green" }
Color.blue
.onHover { _ in HoverState.shared.currentItemText = "Blue" }
}
.padding()
InspectorView()
.frame(width: 100)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
This works because only the inspector view declares a dependency to the hoverState, which also means of course that if you try to reflect a value (as opposed to just setting the value) in the ContentView will not be updated...

UndoManager.setActionName not displayed in menu (SwiftUI macOS)

The Edit menu's Undo/Redo displayed title does not reflect the setActionName in this demo SwiftUI macOS app. The undo/redo functionality works fine and the manager reports back that it has set the action title.
Why is the menu not updated?
autoenablesItems is true for NSApp.menu. When looping through all Windows in NSApp (just one window), the UndoManager (just one for the app) is the same instance as the one SwiftUI presents via Environment. Checking the undo item title via NSApp's reference also shows the item title is set, even though it is not displayed in the Edit menu.
struct ContentView: View {
#Binding var document: DocumentTestDocument
#Environment(\.undoManager) var undo
#StateObject var vm = VM()
#State var autoEnables = false
var body: some View {
VStack(spacing: 25) {
HStack {
Button("<") { vm.performUndo(undo: undo) }
.disabled(!(undo?.canUndo ?? true))
Button("Up") { vm.increment(undo: undo) }
Button("Down") { vm.decrement(undo: undo) }
Button(">") { vm.performRedo(undo: undo) }
.disabled(!(undo?.canRedo ?? true))
}
Text(String(vm.count))
.font(.title)
Text("MenuItemTitle \(vm.title)")
}
.controlSize(.large)
.font(.title3)
.frame(width: 400, height: 300)
.onAppear { DispatchQueue.main.async { autoEnables = NSApp.menu?.autoenablesItems ?? false } }
}
}
class VM: ObservableObject {
#Published var count = 0
#Published var title = ""
func increment(undo: UndoManager?) {
count += 1
undo?.registerUndo(withTarget: self, handler: { (targetSelf) in
targetSelf.decrement(undo: undo)
})
undo?.setActionName("Increment")
title = undo?.undoMenuItemTitle ?? "Nil"
}
func decrement(undo: UndoManager?) {
count -= 1
undo?.registerUndo(withTarget: self, handler: { (targetSelf) in
targetSelf.increment(undo: undo)
})
undo?.setActionName("Increment")
title = undo?.undoMenuItemTitle ?? "Nil"
}
func performUndo(undo: UndoManager?) {
undo?.undo()
}
func performRedo(undo: UndoManager?) {
undo?.redo()
}
}
For some reason, this is not implemented in the new SwiftUI App lifecycle. If you set up your Edit menu in a storyboard and configure the NSHostingView yourself, the Undo menu item titles will change correctly with your existing code. I sure hope this feature is on the way soon, because well-named undos are my favorite part of a polished Mac app!

SwiftUI 2.0 TabView disable swipe to change page

I have a TabView thats using the swiftUI 2.0 PageTabViewStyle. Is there any way to disable the swipe to change pages?
I have a search bar in my first tab view, but if a user is typing, I don't want to give the ability to change they are on, I basically want it to be locked on to that screen until said function is done.
Here's a gif showing the difference, I'm looking to disable tab changing when it's full screen in the gif.
https://imgur.com/GrqcGCI
Try something like the following (tested with some stub code). The idea is to block tab view drag gesture when some condition (in you case start editing) happens
#State var isSearching = false
// ... other code
TabView {
// ... your code here
Your_View()
.gesture(isSearching ? DragGesture() : nil) // blocks TabView gesture !!
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
I tried Asperis's solution, but I still couldn't disable the swiping, and adding disabled to true didn't work since I want the child views to be interactive. The solution that worked for me was using Majid's (https://swiftwithmajid.com/2019/12/25/building-pager-view-in-swiftui/) custom Pager View and adding a conditional like Asperi's solution.
Majid's PagerView with conditional:
import SwiftUI
struct PagerView<Content: View>: View {
let pageCount: Int
#Binding var canDrag: Bool
#Binding var currentIndex: Int
let content: Content
init(pageCount: Int, canDrag: Binding<Bool>, currentIndex: Binding<Int>, #ViewBuilder content: () -> Content) {
self.pageCount = pageCount
self._canDrag = canDrag
self._currentIndex = currentIndex
self.content = content()
}
#GestureState private var translation: CGFloat = 0
var body: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
self.content.frame(width: geometry.size.width)
}
.frame(width: geometry.size.width, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * geometry.size.width)
.offset(x: self.translation)
.animation(.interactiveSpring(), value: currentIndex)
.animation(.interactiveSpring(), value: translation)
.gesture(!canDrag ? nil : // <- here
DragGesture()
.updating(self.$translation) { value, state, _ in
state = value.translation.width
}
.onEnded { value in
let offset = value.translation.width / geometry.size.width
let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
}
)
}
}
}
ContentView:
import SwiftUI
struct ContentView: View {
#State private var currentPage = 0
#State var canDrag: Bool = true
var body: some View {
PagerView(pageCount: 3, canDrag: $canDrag, currentIndex: $currentPage) {
VStack {
Color.blue
Button {
canDrag.toggle()
} label: {
Text("Toogle drag")
}
}
VStack {
Color.red
Button {
canDrag.toggle()
} label: {
Text("Toogle drag")
}
}
VStack {
Color.green
Button {
canDrag.toggle()
} label: {
Text("Toogle drag")
}
}
}
}
}
Ok I think it is possible to block at least 99% swipe gesture if not 100% by using this steps:
and 2.
Add .gesture(DragGesture()) to each page
Add .tabViewStyle(.page(indexDisplayMode: .never))
SwiftUI.TabView(selection: $viewModel.selection) {
ForEach(pages.indices, id: \.self) { index in
pages[index]
.tag(index)
.gesture(DragGesture())
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
Add .highPriorityGesture(DragGesture()) to all remaining views images, buttons that still enable to drag and swipe pages
You can also in 1. use highPriorityGesture but it completely blocks drags on each pages, but I need them in some pages to rotate something
For anyone trying to figure this out, I managed to do this by setting the TabView state to disabled.
TabView(selection: $currentIndex.animation()) {
Items()
}.disabled(true)
Edit: as mentioned in the comments this will disable everything within the TabView as well

Is it possible to change image with fade animation using same Image? (SwiftUI)

According to my logic, on tap gesture to the image it should be changed with fade animation, but actual result is that image changes without animation. Tested with Xcode 11.3.1, Simulator 13.2.2/13.3 if it is important.
P.S. Images are named as "img1", "img2", "img3", etc.
enum ImageEnum: String {
case img1
case img2
case img3
func next() -> ImageEnum {
switch self {
case .img1: return .img2
case .img2: return .img3
case .img3: return .img1
}
}
}
struct ContentView: View {
#State private var img = ImageEnum.img1
var body: some View {
Image(img.rawValue)
.onTapGesture {
withAnimation {
self.img = self.img.next()
}
}
}
}
Update: re-tested with Xcode 13.3 / iOS 15.4
Here is possible approach using one Image (for demo some small modifications made to use default images). The important changes marked with comments.
enum ImageEnum: String {
case img1 = "1.circle"
case img2 = "2.circle"
case img3 = "3.circle"
func next() -> ImageEnum {
switch self {
case .img1: return .img2
case .img2: return .img3
case .img3: return .img1
}
}
}
struct QuickTest: View {
#State private var img = ImageEnum.img1
#State private var fadeOut = false
var body: some View {
Image(systemName: img.rawValue).resizable().frame(width: 300, height: 300)
.opacity(fadeOut ? 0 : 1)
.animation(.easeInOut(duration: 0.25), value: fadeOut) // animatable fade in/out
.onTapGesture {
self.fadeOut.toggle() // 1) fade out
// delayed appear
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
withAnimation {
self.img = self.img.next() // 2) change image
self.fadeOut.toggle() // 3) fade in
}
}
}
}
}
I haven't tested this code, but something like this might be a bit simpler:
struct ContentView: View {
#State private var img = ImageEnum.img1
var body: some View {
Image(img.rawValue)
.id(img.rawValue)
.transition(.opacity.animation(.default))
.onTapGesture {
withAnimation {
self.img = self.img.next()
}
}
}
}
The idea is tell SwiftUI to redraw the Image whenever the filename of the asset changes by binding the View's identity to the filename itself. When the filename changes, SwiftUI assumes the View changed and a new View must be added to the view hierarchy, thus the transition is triggered.

Resources