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 { ... }
}
)
}
Related
I have a MacOS app which has a lot of TextFields in many views; and one editor view which has to receive pressed keyboard shortcut, when cursor is above. But as I try, I cannot focus on a view which is not text enabled. I made a small app to show a problem:
#main
struct TestFocusApp: App {
var body: some Scene {
DocumentGroup(newDocument: TestFocusDocument()) { file in
ContentView(document: file.$document)
}
.commands {
CommandGroup(replacing: CommandGroupPlacement.textEditing) {
Button("Delete") {
deleteSelectedObject.send()
}
.keyboardShortcut(.delete, modifiers: [])
}
}
}
}
let deleteSelectedObject = PassthroughSubject<Void, Never>()
struct MysticView: View {
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.gray.opacity(0.3))
}.focusable()
.onReceive(deleteSelectedObject) { _ in
print ("received")
}
}
}
enum FocusableField {
case wordTwo
case view
case editor
case wordOne
}
struct ContentView: View {
#Binding var document: TestFocusDocument
#State var wordOne: String = ""
#State var wordTwo: String = ""
#FocusState var focus: FocusableField?
var body: some View {
VStack {
TextField("one", text: $wordOne)
.focused($focus, equals: .wordOne)
TextEditor(text: $document.text)
.focused($focus, equals: .editor)
///I want to receive DELETE in any way, in a MystickView or unfocus All another text views in App to not delete their contents
MysticView()
.focusable(true)
.focused($focus, equals: .view)
.onHover { inside in
focus = inside ? .view : nil
/// focus became ALWAYS nil, even set to `.view` here
}
.onTapGesture {
focus = .view
}
TextField("two", text: $wordTwo)
.focused($focus, equals: .wordTwo)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(document: .constant(TestFocusDocument()))
}
}
Only first TextField became focused when I click or hover over MysticView
I can assign nil to focus, but it will not unfocus fields from outside this one view.
Is it a bug, or I missed something? How to make View focusable? To Unfocus all textFields?
I’m trying to build a custom sidebar menu that animates out when a button in it is tapped. For debugging purposes the animation is deliberately slow at 2.0 seconds. As you can see the animation does not work properly:
I suspect there are two parts to this problem:
The background of the newly selected button is moving out faster than the menu. I think this is rooted in the default system animation of Button.
When I replace Button with Text and use an .onTapGesture, there is still the fading animation, so I assume there is something structurally wrong in the way I’m setting selected in FeatureButton.
Sorry the example code is a bit long, tried to simplify my app architecture as much as possible. The reason for using MenuState as an EnvironmentObject is to be able to the change its properties from various places throughout the app.
Here’s the code:
class MenuState: ObservableObject {
#Published var currentFeature: Feature = .featureA
#Published var menuOffset: CGFloat = 0
}
enum Feature: String, CaseIterable {
case featureA = "Feature A"
case featureB = "Feature B"
case featureC = "Feature C"
}
extension Feature: Identifiable {
var id: RawValue { rawValue }
}
struct ContentView: View {
#StateObject var menuState = MenuState()
var body: some View {
ZStack(alignment: .leading) {
content
menu
}
.environmentObject(menuState)
.animation(.easeOut(duration: 2.0), value: menuState.menuOffset)
}
var menu: some View {
VStack {
ForEach(Feature.allCases) { feature in
FeatureButton(feature: feature)
}
}
.frame(maxHeight: .infinity)
.frame(width: 200)
.background(.thinMaterial)
.offset(x: menuState.menuOffset)
}
var content: some View {
VStack {
Button("Show Menu") {
menuState.menuOffset = 0
}
Text(menuState.currentFeature.rawValue)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
struct FeatureButton: View {
#EnvironmentObject var menuState: MenuState
let feature: Feature
var selected: Bool {
return menuState.currentFeature == feature
}
var body: some View {
Button(feature.rawValue) {
menuState.currentFeature = feature
menuState.menuOffset = -200
}
.buttonStyle(FeatureButtonStyle(selected: selected))
}
}
struct FeatureButtonStyle: ButtonStyle {
#EnvironmentObject var menuState: MenuState
var selected: Bool
public func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(maxWidth: .infinity, minHeight: 44)
.foregroundColor(selected ? .blue : .primary)
.background(Color.gray.opacity(selected ? 0.4 : 0))
.contentShape(Rectangle())
}
}
EDIT:
For some reason making the animation explicit solves the issue, see answer below.
The problem can be solved by making the animation explicit instead of using the .animation modifier:
Button(feature.rawValue) {
menuState.currentFeature = feature
withAnimation(.easeOut) {
menuState.menuOffset = -200
}
}
.buttonStyle(FeatureButtonStyle(selected: selected))
I don't understand why it only works like this though.
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
I am currently developing an app for watchOS 6 (independent app) using Swift/SwiftUI in XCode 11.5 on macOS Catalina.
Before a user can use my app, a configuration process is required. As the configuration process consists of several different views which are shown one after each other, I implemented this by using navigation links.
After the configuration process has been finished, the user should click on a button to return to the "main" app (main view). For controlling views which are on the same hierarchical level, my plan was to use an EnvironmentObject (as far as I understood, an EnvironmentObject once injected is handed over to the subviews and subviews can use the EnvironmentObject) in combination with a "controlling view" which controls the display of the views. Therefore, I followed the tutorial: https://blckbirds.com/post/how-to-navigate-between-views-in-swiftui-by-using-an-environmentobject/
This is my code:
ContentView.swift
struct ContentView: View {
var body: some View {
ContentViewManager().environmentObject(AppStateControl())
}
}
struct ContentViewManager: View {
#EnvironmentObject var appStateControl: AppStateControl
var body: some View {
VStack {
if(appStateControl.callView == "AppConfig") {
AppConfig()
}
if(appStateControl.callView == "AppMain") {
AppMain()
}
}
}
}
AppStateControl.swift
class AppStateControl: ObservableObject {
#Published var callView: String = "AppConfig"
}
AppConfig.swift
struct AppConfig: View {
#EnvironmentObject var appStateControl: AppStateControl
var body: some View {
VStack {
Text("App Config Main")
NavigationLink(destination: DetailView1().environmentObject(appStateControl)) {
Text("Show Detail View 1")
}
}
}
}
struct DetailView1: View {
#EnvironmentObject var appStateControl: AppStateControl
var body: some View {
VStack {
Text("App Config Detail View 1")
NavigationLink(destination: DetailView2().environmentObject(appStateControl)) {
Text("Show Detail View 2")
}
}
}
}
struct DetailView2: View {
#EnvironmentObject var appStateControl: AppStateControl
var body: some View {
VStack {
Text("App Config Detail View 2")
Button(action: {
self.appStateControl.callView = "AppMain"
}) {
Text("Go to main App")
}
}
}
}
AppMain.swift
struct AppMain: View {
var body: some View {
Text("Main App")
}
}
In a previous version of my code (without the handing over of the EnvironmentObject all the time) I got a runtime error ("Thread 1: Fatal error: No ObservableObject of type AppStateControl found. A View.environmentObject(_:) for AppStateControl may be missing as an ancestor of this view.") caused by line 41 in AppConfig.swift. In the internet, I read that this is probably a bug of NavigationLink (see: https://www.hackingwithswift.com/forums/swiftui/environment-object-not-being-inherited-by-child-sometimes-and-app-crashes/269, https://twitter.com/twostraws/status/1146315336578469888). Thus, the recommendation was to explicitly pass the EnvironmentObject to the destination of the NavigationLink (above implementation). Unfortunately, this also does not work and instead a click on the button "Go to main App" in "DetailView2" leads to the view "DetailView1" instead of "AppMain".
Any ideas how to solve this problem? To me, it seems that a change of the EnvironmentObject in a view called via a navigation link does not refresh the views (correctly).
Thanks in advance.
One of the solutions is to create a variable controlling whether to display a navigation stack.
class AppStateControl: ObservableObject {
...
#Published var isDetailActive = false // <- add this
}
Then you can use this variable to control the first NavigationLink by setting isActive parameter. Also you need to add .isDetailLink(false) to all subsequent links.
First link in stack:
NavigationLink(destination: DetailView1().environmentObject(appStateControl), isActive: self.$appStateControl.isDetailActive) {
Text("Show Detail View 1")
}
.isDetailLink(false)
All other links:
NavigationLink(destination: DetailView2().environmentObject(appStateControl)) {
Text("Show Detail View 2")
}
.isDetailLink(false)
Then just set isDetailActive to false to pop all your NavigationLinks and return to the main view:
Button(action: {
self.appStateControl.callView = "AppMain"
self.appStateControl.isDetailActive = false // <- add this
}) {
Text("Go to main App")
}
I used the below code to create a custom search bar in SwiftUI. It works great on iOS / Catalyst:
...but when running natively on macOS, the 'focus ring' highlighted border styling (when the user selects the text field) rather ruins the effect:
Using .textFieldStyle(PlainTextFieldStyle()) has removed most of the default styling from the underlying field (which I believe is an NSTextField), but not the focus ring.
Is there a way to remove this too? I tried creating a custom TextFieldStyle and applying that, but couldn't find any modifier to style that border.
public struct SearchTextView: View {
#Binding var searchText: String
#if !os(macOS)
private let backgroundColor = Color(UIColor.secondarySystemBackground)
#else
private let backgroundColor = Color(NSColor.controlBackgroundColor)
#endif
public var body: some View {
HStack {
Spacer()
#if !os(macOS)
Image(systemName: "magnifyingglass")
#else
Image("icons.general.magnifyingGlass")
#endif
TextField("Search", text: self.$searchText)
.textFieldStyle(PlainTextFieldStyle())
.foregroundColor(.primary)
.padding(8)
Spacer()
}
.foregroundColor(.secondary)
.background(backgroundColor)
.cornerRadius(12)
.padding()
}
public init(searchText: Binding<String>) {
self._searchText = searchText
}
}
As stated in an answer by Asperi to a similar question here, it's not (yet) possible 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 { }
}
}
If you want to replace this with your own custom focus ring within the view, the onEditingChanged parameter can help you achieve this (see below example); however, it's unfortunately called on macOS when the user types the first letter, not when they first click on the field (which isn't ideal).
In theory, you could use the onFocusChange closure in the focusable modifier instead, but that doesn't appear to get called for these macOS text fields currently (as of macOS 10.15.3).
public struct SearchTextView: View {
#Binding var searchText: String
#State private var hasFocus = false
#if !os(macOS)
private var backgroundColor = Color(UIColor.secondarySystemBackground)
#else
private var backgroundColor = Color(NSColor.controlBackgroundColor)
#endif
public var body: some View {
HStack {
Spacer()
#if !os(macOS)
Image(systemName: "magnifyingglass")
#else
Image("icons.general.magnifyingGlass")
#endif
TextField("Search", text: self.$searchText, onEditingChanged: { currentlyEditing in
self.hasFocus = currentlyEditing // If the editing state has changed to be currently edited, update the view's state
})
.textFieldStyle(PlainTextFieldStyle())
.foregroundColor(.primary)
.padding(8)
Spacer()
}
.foregroundColor(.secondary)
.background(backgroundColor)
.cornerRadius(12)
.border(self.hasFocus ? Color.accentColor : Color.clear, width: self.hasFocus ? 3 : 0)
.padding()
}
public init(searchText: Binding<String>) {
self._searchText = searchText
}
}