I'm building a macOS 13 app using SwiftUI. The app has two-column navigation by using the NavigationSplitView which gives a sidebar and detail view. The detail views are different sizes so I would like the window to change size based on the size of each detail view.
In the example below, the detail views are AppleView, KiwiView, and PeachView. The AppleView has a size of 400x300 and the KiwiView is 300x200. When I run the app, the window does not adjust its size when the detail view changes. I tried to wrap the navigation view in a VStack but that did not help. Does anyone know how I can get the app's window to adjust size based on the selected detail view?
import SwiftUI
struct AppleView: View {
var body: some View {
Text("Apple View 🍎")
.font(.title)
.frame(width: 400, height: 300)
.background(.red)
}
}
struct KiwiView: View {
var body: some View {
Text("Kiwi View 🥝")
.font(.title)
.frame(width: 300, height: 200)
.background(.green)
}
}
struct PeachView: View {
var body: some View {
Text("Peach View 🍑")
.font(.title)
.background(.pink)
}
}
enum Fruit: String, CaseIterable {
case apple = "Apple"
case kiwi = "Kiwi"
case peach = "Peach"
}
struct ContentView: View {
#State private var selectedFruit: Fruit = .apple
var body: some View {
NavigationSplitView {
List(Fruit.allCases, id: \.self, selection: $selectedFruit) { fruit in
Text(fruit.rawValue)
}
} detail: {
switch selectedFruit {
case .apple:
AppleView()
case .kiwi:
KiwiView()
case .peach:
PeachView()
}
}
}
}
I also tried setting the .windowResizability() of the main window group but that didn't fix the problem.
import SwiftUI
#main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.windowResizability(.contentSize)
}
}
sorry for the late reply..
It would apply to NavigationSplitView just the same.
Since you want to have fixed sizes, I would add frames to all the sub-Views for consistent behaviour, so in this example the PeachView and the List
and then you simply modify your ContentView accordingly:
struct ContentView: View {
#State private var selectedFruit: Fruit = .apple
#State private var window: NSWindow?
var body: some View {
NavigationSplitView {
List(Fruit.allCases, id: \.self, selection: $selectedFruit) { fruit in
Text(fruit.rawValue)
}
.frame(width: 100)
} detail: {
switch selectedFruit {
case .apple:
AppleView()
case .kiwi:
KiwiView()
case .peach:
PeachView()
}
}
.background(WindowAccessor(window: $window))
.onChange(of: selectedFruit) { newValue in
if newValue == .apple {
window?.setFrame(NSRect(x: 600, y: 400, width: 500, height: 600), display: true)
} else if newValue == .kiwi {
window?.setFrame(NSRect(x: 600, y: 400, width: 400, height: 500), display: true)
} else if newValue == .peach {
window?.setFrame(NSRect(x: 600, y: 400, width: 350, height: 400), display: true)
}
}
}
}
And then add the WindowAccessor :
struct WindowAccessor: NSViewRepresentable {
#Binding var window: NSWindow?
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
self.window = view.window
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
For full access of the Window across the app you might also wanna look into accessing it with App/Scene Delegate, as explained here
EDIT:
Addressing shifting behaviour
You can position the Window anywhere you want, specified by the x and y coordinates of the NSRect you create in window?.setFrame. Since you didnt say what kind of positioning you'd like, I now 'pinned' the window to the top-left-corner, by accessing the underlying screen as follows:
.onChange(of: selectedFruit) { newValue in
if newValue == .apple {
window?.setFrame(NSRect(x: 0, y: window?.screen?.frame.maxY ?? 500, width: 500, height: 600), display: true)
} else if newValue == .kiwi {
window?.setFrame(NSRect(x: 0, y: window?.screen?.frame.maxY ?? 500, width: 400, height: 500), display: true)
} else if newValue == .peach {
window?.setFrame(NSRect(x: 0, y: window?.screen?.frame.maxY ?? 500, width: 400, height: 300), display: true)
}
}
For more complex positions you just need to calculate frame dimensions and origin, according to your wanted behaviour. The detail view beeing clipped is fixed by adjusting frame size.
Further down the line you might find this helpful when dealing with MacOS saving the last position and size of the window.
Related
I'm creating vertical layout which has scrollable horizontal LazyHGrid in it. The problem is that views in LazyHGrid can have different heights (primarly because of dynamic text lines) but the grid always calculates height of itself based on first element in grid:
What I want is changing size of that light red rectangle based on visible items, so when there are smaller items visible it should look like this:
and when there are bigger items it should look like this:
This is code which results in state on the first image:
struct TestView: PreviewProvider {
static var previews: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem()],
alignment: .top,
spacing: 16
) {
Color.red
.frame(width: 64, height: 24)
ForEach(Array(0...10), id: \.self) { value in
Color.red
.frame(width: 64, height: CGFloat.random(in: 32...92))
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}
}
}
Something similar what I want can be achieved by this:
extension View {
func readSize(edgesIgnoringSafeArea: Edge.Set = [], onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
SwiftUI.Color.clear
.preference(key: ReadSizePreferenceKey.self, value: geometryProxy.size)
}.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
)
.onPreferenceChange(ReadSizePreferenceKey.self) { size in
DispatchQueue.main.async { onChange(size) }
}
}
}
struct ReadSizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
struct Size: Equatable {
var height: CGFloat
var isValid: Bool
}
struct TestView: View {
#State private var sizes = [Int: Size]()
#State private var height: CGFloat = 32
static let values: [(Int, CGFloat)] =
(0...3).map { ($0, CGFloat(32)) }
+ (4...10).map { ($0, CGFloat(92)) }
var body: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem(.fixed(height))],
alignment: .top,
spacing: 16
) {
ForEach(Array(Self.values), id: \.0) { value in
Color.red
.frame(width: 300, height: value.1)
.readSize { sizes[value.0]?.height = $0.height }
.onAppear {
if sizes[value.0] == nil {
sizes[value.0] = Size(height: .zero, isValid: true)
} else {
sizes[value.0]?.isValid = true
}
}
.onDisappear { sizes[value.0]?.isValid = false }
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}.onChange(of: sizes) { sizes in
height = sizes.filter { $0.1.isValid }.map { $0.1.height }.max() ?? 32
}
}
}
... but as you see its kind of laggy and a little bit complicated, isn't there better solution? Thank you everyone!
The height of a row in a LazyHGrid is driven by the height of the tallest cell. According to the example you provided, the data source will only show a smaller height if it has only a small size at the beginning.
Unless the first rendering will know that there are different heights, use the larger value as the height.
Is your expected UI behaviour that the height will automatically switch? Or use the highest height from the start.
I would like to position the Settings window, e.g.:
Horizontal: centered in the screen
Vertical: 200px from screen top
The code below does position it, but with some delay. It first appears where the system decides to put it, then my positioning is applied. I would like to avoid this "flash" effect.
import SwiftUI
#main
struct MacosPlaygroundApp: App {
var body: some Scene {
WindowGroup("Playground") {
Text("Hello, World!")
.frame(width: 700, height: 300, alignment: .center)
}
Settings {
SettingsView()
}
}
}
struct SettingsView: View {
#State private var selectedTab: SettingsTab = .first
var body: some View {
TabView {
Text("First settings")
.tabItem { Label("First", systemImage: "1.square") }
.tag(SettingsTab.first)
Text("Second settings")
.tabItem { Label("Second", systemImage: "2.square") }
.tag(SettingsTab.second)
}
.frame(width: 400, height: 200, alignment: .top)
.onAppear {
// I wrap it in a Task because `settingsWindow` would be `nil` otherwise
Task {
guard let settingsWindow = NSApplication.shared.windows.first(where: { $0.title == windowTitle }) else { return }
guard let screen = settingsWindow.screen else { return }
let settingsWindowSize = settingsWindow.frame.size
let screenSize = screen.frame.size
let left = (screenSize.width - settingsWindowSize.width) / 2
let bottom = screenSize.height - 200
settingsWindow.setFrameTopLeftPoint(NSPoint(x: left, y: bottom))
}
}
}
var windowTitle: String {
switch selectedTab {
case .first:
return "First"
case .second:
return "Second"
}
}
enum SettingsTab {
case first
case second
}
}
I'd like to add a full size AccessoryWidgetBackground() to an accessoryRectangular widget family.
I made a brand new project and added a brand new widget. Then I changed the view to:
struct Some_WidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
AccessoryWidgetBackground()
.frame(width: .infinity, height: .infinity)
.ignoresSafeArea()
}
}
Here's what I get:
Is there a way to take up the whole view with the background blur?
Here is a possible solution. Tested with Xcode 11.4 / iOS 13.4
struct Some_WidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
ZStack {
Color.clear.edgesIgnoringSafeArea(.all) // << here !!
AccessoryWidgetBackground()
.edgesIgnoringSafeArea(.all)
}
}
}
The answer to the question as posed is: this is not possible in iOS 16.0, the view is cropped as can be seen from the inner box in the image in the question.
The workaround I settled for is to create a rounded background view and add it in a ZStack:
var body: some View {
ZStack {
OptionalBlurView(showBlur: true)
// etc
Here's the background view, it works in previews too.
#available(iOSApplicationExtension 16.0, *)
struct OptionalBlurView: View {
var showBlur: Bool
#Environment(\.widgetFamily) var family
var body: some View {
if showBlur {
blurView
} else {
EmptyView()
}
}
var blurView: some View {
#if targetEnvironment(simulator)
// at the time of coding, AccessoryWidgetBackground does not show in previews, so this is an aproximation
switch family {
case .accessoryCircular:
return Circle()
.opacity(0.3)
.eraseToAnyView()
default:
return Rectangle()
.clipShape(RoundedRectangle(cornerSize: CGSize(width: 10, height: 10), style: .continuous))
.opacity(0.3)
.eraseToAnyView()
}
#else
AccessoryWidgetBackground()
.clipShape(RoundedRectangle(cornerSize: CGSize(width: 10, height: 10), style: .continuous))
.opacity(0.7)
#endif
}
}
I'm trying to learn SwiftUI, and I had a question about how to make a component that has a handle on it which you can use to drag around. There are several tutorials online about how to make a draggable component, but none of them exactly answer the question I have, so I thought I would seek the wisdom of you fine people.
Lets say you have a view that's like a window with a title bar. For simplicity's sake, lets make it like this:
struct WindowView: View {
var body: some View {
VStack(spacing:0) {
Color.red
.frame(height:25)
Color.blue
}
}
}
I.e. the red part at the top is the title bar, and the main body of the component is the blue area. Now, this window view is contained inside another view, and you can drag it around. The way I've read it, you should do something like this (very simplified):
struct ContainerView: View {
#State private var loc = CGPoint(x:150, y:150);
var body: some View {
ZStack {
WindowView()
.frame(width:100, height:100)
.position(loc)
.gesture(DragGesture()
.onChanged { value in
loc = value.location
}
)
}
}
}
and that indeed lets you drag the component around (ignore for now that we're always dragging by the center of the image, it's not really the point):
However, this is not what I want: I don't want you to be able to drag the component around by just dragging inside the window, I only want to drag it around by dragging the red title bar. But the red title-bar is hidden somewhere inside of WindowView. I don't want to move the #State variable containing the position to inside the WindowView, it seems to me much more logical to have that inside ContainerView. But then I need to somehow forward the gesture into the embedded title bar.
I imagine the best way would be for the ContainerView to look something like this:
struct ContainerView: View {
#State private var loc = CGPoint(x:150, y:150);
var body: some View {
ZStack {
WindowView()
.frame(width:100, height:100)
.position(loc)
.titleBarGesture(DragGesture()
.onChanged { value in
loc = value.location
}
)
}
}
}
but I don't know how you would implement that .titleBarGesture in the correct way (or if this is even the proper way to do it. should the gesture be an argument to the WindowView constructor?). Can anyone help me out, give me some pointers?
Thanks in advance!
You can get smooth translation of the window using the offset from the drag, and then disable touch on the background element to prevent content from dragging.
Buttons still work in the content area.
import SwiftUI
struct WindowBar: View {
#Binding var location: CGPoint
var body: some View {
ZStack {
Color.red
.frame(height:25)
Text(String(format: "%.1f: %.1f", location.x, location.y))
}
}
}
struct WindowContent: View {
var body: some View {
ZStack {
Color.blue
.allowsHitTesting(false) // background prevents interaction
Button("Press Me") {
print("Tap")
}
}
}
}
struct WindowView: View, Identifiable {
#State var location: CGPoint // The views current center position
let id = UUID()
/// Keep track of total translation so that we don't jump on finger drag
/// SwiftUI doesn't have an onBegin callback like UIKit's gestures
#State private var startDragLocation = CGPoint.zero
#State private var isBeginDrag = true
init(location: CGPoint = .zero) {
_location = .init(initialValue: location)
}
var body: some View {
VStack(spacing:0) {
WindowBar(location: $location)
WindowContent()
}
.frame(width: 100, height: 100)
.position(location)
.gesture(DragGesture()
.onChanged({ value in
if isBeginDrag {
isBeginDrag = false
startDragLocation = location
}
// In UIKit we can reset translation to zero, but we can't in SwiftUI
// So we do book keeping to track startLocation of gesture and adjust by
// total translation
location = CGPoint(x: startDragLocation.x + value.translation.width,
y: startDragLocation.y + value.translation.height)
})
.onEnded({ value in
isBeginDrag = true /// reset for next drag
}))
}
}
struct ContainerView: View {
#State private var windows = [
WindowView(location: CGPoint(x: 50, y: 100)),
WindowView(location: CGPoint(x: 190, y: 75)),
WindowView(location: CGPoint(x: 250, y: 50))
]
var body: some View {
ZStack {
ForEach(windows) { window in
window
}
}
.frame(width: 600, height: 480)
}
}
struct ContentView: View {
var body: some View {
ContainerView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You can just use .allowsHitTesting(false) on the blue view, which will ignore the touch gesture on that view. Hence, you can only drag on the red View and still have the DragGesture outside that view.
struct WindowView: View {
var body: some View {
VStack(spacing:0) {
Color.red
.frame(height:25)
Color.blue
.allowsHitTesting(false)
}
}
}
You can wrap the DragGesture into a ViewModifier:
struct MovableByBar: ViewModifier {
static let barHeight: CGFloat = 14
#State private var loc: CGPoint!
#State private var transition: CGSize = .zero
func body(content: Content) -> some View {
if loc == nil { // Get the original position
content.padding(.top, MovableByBar.barHeight)
.overlay {
GeometryReader { geo -> Color in
DispatchQueue.main.async {
let frame = geo.frame(in: .local)
loc = CGPoint(x: frame.midX, y: frame.midY)
}
return Color.clear
}
}
} else {
VStack(spacing: 0) {
Rectangle()
.fill(.secondary)
.frame(height: MovableByBar.barHeight)
.offset(x: transition.width, y: transition.height)
.gesture (
DragGesture()
.onChanged { value in
transition = value.translation
}
.onEnded { value in
loc.x += transition.width
loc.y += transition.height
transition = .zero
}
)
content
.offset(x: transition.width,
y: transition.height)
}
.position(loc)
}
}
}
And use modifier like this:
WindowView()
.modifier(MovableByBar())
.frame(width: 80, height: 60) // `frame()` after it
I saw the position property but I think that is just used to set x and y, but I don't know what about recognizing current location.
Or totally how to use events income properties like onHover?
See What is Geometry Reader in SwiftUI?, specifically the discussion about GeometryGetter. If you place a GeometryGetter at the top of your ScrollView contents, it will emit its frame using the binding you pass to it. The origin of this frame will be the negative content offset of the scroll view.
In the following example you see how you can use GeometryReader to get the horizontal position of the content in the scroll view. However, I did not succeed yet in finding out how to set the scroll position. (Xcode 11.0 beta 6 (11M392q))
struct TimelineView: View {
#State private var posX: CGFloat = 0
var body: some View {
GeometryReader { geo in
VStack {
Text("\(self.posX)")
ScrollView(.horizontal, showsIndicators: true) {
VStack {
GeometryReader { innerGeo -> Text in
self.posX = innerGeo.frame(in: .global).minX
return Text("")
}
TimelineGridView()
}
}
.position(x: geo.size.width / 2, y: geo.size.height / 2)
}
}
}
}
where:
struct TimelineGridView: View {
var body: some View {
VStack {
ForEach(0...10, id: \.self) { rowIndex in
TimelineRowView()
}
}
}
}
struct TimelineRowView: View {
var body: some View {
HStack {
ForEach(0...100, id: \.self) { itemIndex in
TimelineCellView()
}
}
}
}
struct TimelineCellView: View {
var body: some View {
Rectangle()
.fill(Color.yellow)
.opacity(0.5)
.frame(width: 10, height: 10, alignment: .bottomLeading)
}
}
```
You can use TrackableScrollView by #maxnatchanon
Here is the code:
//
// TrackableScrollView.swift
// TrackableScrollView
//
// Created by Frad LEE on 2020/6/21.
// Copyright © 2020 Frad LEE. All rights reserved.
//
import SwiftUI
/// A trackable and scrollable view. Read [this link](https://medium.com/#maxnatchanon/swiftui-how-to-get-content-offset-from-scrollview-5ce1f84603ec) for more.
///
/// The trackable scroll view displays its content within the trackable scrollable content region.
///
/// # Usage
///
/// ``` swift
/// struct ContentView: View {
/// #State private var scrollViewContentOffset = CGFloat(0) // Content offset available to use
///
/// var body: some View {
/// TrackableScrollView(.vertical, showIndicators: false, contentOffset: $scrollViewContentOffset) {
/// ...
/// }
/// }
/// }
/// ```
struct TrackableScrollView<Content>: View where Content: View {
let axes: Axis.Set
let showIndicators: Bool
#Binding var contentOffset: CGFloat
let content: Content
/// Creates a new instance that’s scrollable in the direction of the given axis and can show indicators while scrolling.
/// - Parameters:
/// - axes: The scrollable axes of the scroll view.
/// - showIndicators: A value that indicates whether the scroll view displays the scrollable component of the content offset, in a way that’s suitable for the platform.
/// - contentOffset: A value that indicates offset of content.
/// - content: The scroll view’s content.
init(_ axes: Axis.Set = .vertical, showIndicators: Bool = true, contentOffset: Binding<CGFloat>, #ViewBuilder content: () -> Content) {
self.axes = axes
self.showIndicators = showIndicators
_contentOffset = contentOffset
self.content = content()
}
var body: some View {
GeometryReader { outsideProxy in
ScrollView(self.axes, showsIndicators: self.showIndicators) {
ZStack(alignment: self.axes == .vertical ? .top : .leading) {
GeometryReader { insideProxy in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: [self.calculateContentOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy)])
}
VStack {
self.content
}
}
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
self.contentOffset = value[0]
}
}
}
private func calculateContentOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
if axes == .vertical {
return outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY
} else {
return outsideProxy.frame(in: .global).minX - insideProxy.frame(in: .global).minX
}
}
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
typealias Value = [CGFloat]
static var defaultValue: [CGFloat] = [0]
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
If you don't find any option. You can still use the standard UIScrollView with their delegates with UIViewRepresentable by making a separate struct an conforming to it.
More Detail on that you can find on the SwiftUI Tutorials:
https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit
I created SwiftPackage for this purpose(Pure SwiftUI 👍)
With this lib, you can get position of ScrollView.
https://github.com/kazuooooo/PositionScrollView
Medium post
import Foundation
import SwiftUI
/// Extended ScrollView which can controll position
public struct MinimalHorizontalExample: View, PositionScrollViewDelegate {
/// Page size of Scroll
var pageSize = CGSize(width: 200, height: 300)
// Create PositionScrollViewModel
// (Need to create in parent view to bind the state between this view and PositionScrollView)
#ObservedObject var psViewModel = PositionScrollViewModel(
pageSize: CGSize(width: 200, height: 300),
horizontalScroll: Scroll(
scrollSetting: ScrollSetting(pageCount: 5, afterMoveType: .stickNearestUnitEdge),
pageLength: 200 // Page length of direction
)
)
public var body: some View {
return VStack {
PositionScrollView(
viewModel: self.psViewModel,
delegate: self
) {
HStack(spacing: 0) {
ForEach(0...4, id: \.self){ i in
ZStack {
Rectangle()
.fill(BLUES[i])
.border(Color.black)
.frame(
width: self.pageSize.width, height: self.pageSize.height
)
Text("Page\(i)")
.foregroundColor(Color.white)
.font(.system(size: 24, weight: .heavy, design: .default))
}
}
}
}
// Get page via scroll object
Text("page: \(self.psViewModel.horizontalScroll?.page ?? 0)")
// Get position via scroll object
Text("position: \(self.psViewModel.horizontalScroll?.position ?? 0)")
}
}
struct SampleView_Previews: PreviewProvider {
static var previews: some View {
return MinimalHorizontalExample()
}
}
// Delegate methods of PositionScrollView
// You can monitor changes of position
public func onScrollStart() {
print("onScrollStart")
}
public func onChangePage(page: Int) {
print("onChangePage to page: \(page)")
}
public func onChangePosition(position: CGFloat) {
print("position: \(position)")
}
public func onScrollEnd() {
print("onScrollEnd")
}
}