When applying .rotationEffect() to a Text, it rotates the text as expected, but its frame remains unchanged. This becomes an issue when stacking rotated views with non-rotated views, such as with a VStack of HStack, causing them to overlap.
I initially thought the rotationEffect would simply update the frame of the Text to be vertical, but this is not the case.
I've tried manually setting the frame size and (if needed, offsetting) the Text, which sort of works, but I don't like this solution because it requires some guessing and checking of where the Text will appear, how big to make the frame, etc.
Is this just how rotated text is done, or is there a more elegant solution to this?
struct TextAloneView: View {
var body: some View {
VStack {
Text("Horizontal text")
Text("Vertical text").rotationEffect(.degrees(-90))
}
}
}
Overlapping Text
You need to adjust the frame yourself in this case. That requires capturing what the frame is, and then applying the adjustment.
First, to capture the existing frame, create a preference, which is a system for passing data from child views to their parents:
private struct SizeKey: PreferenceKey {
static let defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
extension View {
func captureSize(in binding: Binding<CGSize>) -> some View {
overlay(GeometryReader { proxy in
Color.clear.preference(key: SizeKey.self, value: proxy.size)
})
.onPreferenceChange(SizeKey.self) { size in binding.wrappedValue = size }
}
}
This creates a new .captureSize(in: $binding) method on Views.
Using that, we can create a new kind of View that rotates its frame:
struct Rotated<Rotated: View>: View {
var view: Rotated
var angle: Angle
init(_ view: Rotated, angle: Angle = .degrees(-90)) {
self.view = view
self.angle = angle
}
#State private var size: CGSize = .zero
var body: some View {
// Rotate the frame, and compute the smallest integral frame that contains it
let newFrame = CGRect(origin: .zero, size: size)
.offsetBy(dx: -size.width/2, dy: -size.height/2)
.applying(.init(rotationAngle: CGFloat(angle.radians)))
.integral
return view
.fixedSize() // Don't change the view's ideal frame
.captureSize(in: $size) // Capture the size of the view's ideal frame
.rotationEffect(angle) // Rotate the view
.frame(width: newFrame.width, // And apply the new frame
height: newFrame.height)
}
}
And for convenience, an extension to apply it:
extension View {
func rotated(_ angle: Angle = .degrees(-90)) -> some View {
Rotated(self, angle: angle)
}
}
And now your code should work as you expect:
struct TextAloneView: View {
var body: some View {
VStack {
Text("Horizontal text")
Text("Vertical text").rotated()
}
}
}
RotationEffect takes a second argument which is the anchor point, if you omit it - the default is .center.
Try this instead:
.rotationEffect(.degrees(-90), anchor: .bottomTrailing)
Related
Here is a breakdown.
I have a zstack that contains 2 vstacks.
first vstack has a spacer and an image
second has a text and button.
ZStack {
VStack {
Spacer()
Image("some image")
}
VStack {
Text("press the button")
Button("ok") {
print("you pressed the button")
}
}
}
Now this setup would easily give me an image on the bottom of a zstack, and a centered title and button.
However if for example the device had a small screen or an ipad rotates to landscape. depending on the image size (which is dynamic). The title and button will overlap the image. instead of the button being "pushed" up.
In UIKit this is as simple as centering the button to superview with a high priority and having greaterThanOrEqualTo image.topAnchor with a required priority.
button would be centered in screen but if the top of the image was too big the center constraint would give priority to the image top anchor required constraint and push the button up.
I have looked into custom alignments and can easily get always above image or always center but am missing some insight in having it both depending on layout. Image size is dynamic so no hardcoded sizes.
What am i missing here? how would you solve this simple yet tricky task.
There might be an easier way using .alignmentGuide but I tried to practice on Layout for this answer.
I created a custom ImageAndButtonLayout that should do what you want: it takes two views assuming the first is the image and the second is the button (or anything else).
They are put into subviews just for clarity, you can also put them directly into ImageAndButtonLayout. For testing you can change the height of the image via slider.
The Layout always uses the available full height and pushes the first view (image) to the bottom - so you don't need an extra Spacer() with the image. The position of the second view (button) is calculated based on the height of the first view and the available height.
struct ContentView: View {
#State private var imageHeight = 200.0 // for testing
var body: some View {
VStack {
ImageAndButtonLayout {
imageView
buttonView
}
// changing "image" height for testing
Slider(value: $imageHeight, in: 50...1000)
.padding()
}
}
var imageView: some View {
Color.teal // Image placeholder
.frame(height: imageHeight)
}
var buttonView: some View {
VStack {
Text("press the button")
Button("ok") {
print("you pressed the button")
}
}
}
}
struct ImageAndButtonLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxsizes = subviews.map { $0.sizeThatFits(.infinity) }
var totalWidth = maxsizes.max {$0.width < $1.width}?.width ?? 0
totalWidth = min(totalWidth, proposal.width ?? .infinity )
let totalHeight = proposal.height ?? .infinity // always return maximum height
return CGSize(width: totalWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let heightImage = subviews.first?.sizeThatFits(.unspecified).height ?? 0
let heightButton = subviews.last?.sizeThatFits(.unspecified).height ?? 0
let maxHeightContent = bounds.height
// place image at bottom, growing upwards
let ptBottom = CGPoint(x: bounds.midX, y: bounds.maxY) // bottom of screen
if let first = subviews.first {
var totalWidth = first.sizeThatFits(.infinity).width
totalWidth = min(totalWidth, proposal.width ?? .infinity )
first.place(at: ptBottom, anchor: .bottom, proposal: .init(width: totalWidth, height: maxHeightContent))
}
// place button at center – or above image
var centerY = bounds.midY
if heightImage > maxHeightContent / 2 - heightButton {
centerY = maxHeightContent - heightImage
centerY = max ( heightButton * 2 , centerY ) // stop at top of screen
}
let ptCenter = CGPoint(x: bounds.midX, y: centerY)
if let last = subviews.last {
last.place(at: ptCenter, anchor: .center, proposal: .unspecified)
}
}
}
I want to use string interpolation on an SF Symbol that has a rotationEffect(_:anchor:) modifier applied to it. Is it possible to do this?
Without the modifier this type of string interpolation works fine (in Swift 5.0):
struct ContentView: View {
var body: some View {
Text("Some text before \(Image(systemName: "waveform.circle")) plus some text after.")
}
}
But applying the modifier like this:
struct ContentView: View {
var body: some View {
Text("Some text before \(Image(systemName: "waveform.circle").rotationEffect(.radians(.pi * 0.5))) plus some text after.")
}
}
doesn't compile and gives this error:
Instance method 'appendInterpolation' requires that 'some View' conform to '_FormatSpecifiable'
The Text interpolation expect an Image. When the .rotationEffect...
is applied it becomes a View, and this is not valid.
So an alternative is to rotate the SF before it is used in Image.
This is what I ended up trying, using the code from one of the answers at: Rotating UIImage in Swift
to rotate a UIImage and using that in the Image.
It is a bit convoluted, and you will probably
have to adjust the anchor/position.
Perhaps someone will come up with a better solution. Until then
it seems to works for me.
struct ContentView: View {
var body: some View {
Text("Some text before \(img) plus some text after.")
}
var img: Image {
if let uimg = UIImage(systemName: "waveform.circle"),
let rotImage = uimg.rotate(radians: .pi/4) {
return Image(uiImage: rotImage)
} else {
return Image(systemName: "waveform.circle")
}
}
}
// from: https://stackoverflow.com/questions/27092354/rotating-uiimage-in-swift
extension UIImage {
func rotate(radians: Float) -> UIImage? {
var newSize = CGRect(origin: CGPoint.zero, size: self.size).applying(CGAffineTransform(rotationAngle: CGFloat(radians))).size
// Trim off the extremely small float value to prevent core graphics from rounding it up
newSize.width = floor(newSize.width)
newSize.height = floor(newSize.height)
UIGraphicsBeginImageContextWithOptions(newSize, false, self.scale)
let context = UIGraphicsGetCurrentContext()!
// Move origin to middle
context.translateBy(x: newSize.width/2, y: newSize.height/2)
// Rotate around middle
context.rotate(by: CGFloat(radians))
// Draw the image at its center
self.draw(in: CGRect(x: -self.size.width/2, y: -self.size.height/2, width: self.size.width, height: self.size.height))
let newImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return newImage
}
}
As pointed out here by workingdog_support_Ukraine, string interpolation will work with Image but modifiers will change the type they are applied to. So we need to rotate the image without erasing the Image type.
For simple orientation rotations we can create our rotated Image type like this:
extension Image {
init(systemName: String, orientation: UIImage.Orientation) {
guard
let uiImage = UIImage(systemName: systemName),
let cgImage = uiImage.cgImage
else {
self.init(systemName: systemName)
return
}
self.init(uiImage: UIImage(cgImage: cgImage, scale: uiImage.scale, orientation: orientation))
}
}
We then use a rotated image (in this case, rotated 90 degrees clockwise) in our string interpolation, as follows:
struct ContentView: View {
var body: some View {
Text("Some text before \(Image(systemName: "waveform.circle", orientation: .right)) plus some text after.")
}
}
This aligns the rotated image on the text baseline.
Here I have this question when I try to give a View an initial position, then user can use drag gesture to change the location of the View to anywhere. Although I already solved the issue by only using .position(x:y:) on a View, at the beginning I was thinking using .position(x:y:) to give initial position and .offset(offset:) to make the View move with gesture, simultaneously. Now, I really just want to know in more detail, what exactly happens when I use both of them the same time (the code below), so I can explain what happens in the View below.
What I cannot explain in the View below is that: when I simply drag gesture on the VStack box, it works as expected and the VStack moves with finger gesture, however, once the gesture ends and try to start a new drag gesture on the VStack, the VStack box goes back to the original position suddenly (like jumping to the original position when the code is loaded), then start moving with the gesture. Note that the gesture is moving as regular gesture, but the VStack already jumped to a different position so it starts moving from a different position. And this causes that the finger tip is no long on top of the VStack box, but off for some distance, although the VStack moves with the same trajectory as drag gesture does.
My question is: why the .position(x:y:) modifier seems only take effect at the very beginning of each new drag gesture detected, but during the drag gesture action on it seems .offset(offset:) dominates the main movement and the VStack stops at where it was dragged to. But once new drag gesture is on, the VStack jumps suddenly to the original position. I just could not wrap my head around how this behavior happens through timeline. Can somebody provide some insights?
Note that I already solved the issue to achieve what I need, right now it's just to understand what is exactly going on when .position(x:y:) and .offset(offset:) are used the same time, so please avoid some advice like. not use them simultaneously, thank you. The code bellow suppose to be runnable after copy and paste, if not pardon me for making mistake as I delete few lines to make it cleaner to reproduce the issue.
import SwiftUI
struct ContentView: View {
var body: some View {
ButtonsViewOffset()
}
}
struct ButtonsViewOffset: View {
let location: CGPoint = CGPoint(x: 50, y: 50)
#State private var offset = CGSize.zero
#State private var color = Color.purple
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
self.offset = value.translation
print("offset onChange: \(offset)")
}
.onEnded{ _ in
if self.color == Color.purple{
self.color = Color.blue
}
else{
self.color = Color.purple
}
}
}
var body: some View {
VStack {
Text("Watch 3-1")
Text("x: \(self.location.x), y: \(self.location.y)")
}
.background(Color.gray)
.foregroundColor(self.color)
.offset(self.offset)
.position(x: self.location.x, y: self.location.y)
.gesture(dragGesture)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView()
}
}
}
Your issue has nothing to do with the use of position and offset. They actually both work simultaneously. Position sets the absolute position of the view, where as offset moves it relative to the absolute position. Therefore, you will notice that your view starts at position (50, 50) on the screen, and then you can drag it all around. Once you let go, it stops wherever it was. So far, so good. You then want to move it around again, and it pops back to the original position. The reason it does that is the way you set up location as a let constant. It needs to be state.
The problem stems from the fact that you are adding, without realizing it, the values of offset to position. When you finish your drag, offset retains the last values. However, when you start your next drag, those values start at (0,0) again, therefore the offset is reset to (0,0) and the view moves back to the original position. The key is that you need to use just the position or update the the offset in .onEnded. Don't use both. Here you have a set position, and are not saving the offset. How you handle it depends upon the purpose for which you are moving the view.
First, just use .position():
struct OffsetAndPositionView: View {
#State private var position = CGPoint(x: 50, y: 50)
#State private var color = Color.purple
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
position = value.location
print("position onChange: \(position)")
}
.onEnded{ value in
if color == Color.purple{
color = Color.blue
}
else{
color = Color.purple
}
}
}
var body: some View {
Rectangle()
.fill(color)
.frame(width: 30, height: 30)
.position(position)
.gesture(dragGesture)
}
}
Second, just use .offset():
struct ButtonsViewOffset: View {
#State private var savedOffset = CGSize.zero
#State private var dragValue = CGSize.zero
#State private var color = Color.purple
var offset: CGSize {
savedOffset + dragValue
}
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
dragValue = value.translation
print("dragValue onChange: \(dragValue)")
}
.onEnded{ value in
savedOffset = savedOffset + value.translation
dragValue = CGSize.zero
if color == Color.purple{
color = Color.blue
}
else{
color = Color.purple
}
}
}
var body: some View {
Rectangle()
.fill(color)
.frame(width: 30, height: 30)
.offset(offset)
.gesture(dragGesture)
}
}
// Convenience operator overload
func + (lhs: CGSize, rhs: CGSize) -> CGSize {
return CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
}
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.
I'm trying to mimic ScrollView behaviour in my own custom component.
The thing I'm having trouble with is scroll animation. As far I understand I can use predictedEndTranslation object to get the predicted position of the drag gesture. But I don't know which easing function I should use to mimic default ScrollView easing.
Here is my code
struct ViewContent: View {
#State var offsetY: CGFloat = 0
var body: some View {
View {
}
.offset(y: offsetY)
.gesture(
DragGesture()
.onChange { value in
offsetY = value.translation.height
}
.onEnded { value in
let nextScrollPosition = value.predictedEndTranslation.height
withAnimation(???) { // What easing to use?
offsetY = nextScrollPosition
}
}
)
}
}
I would use .spring() as this preset animation will get you the closes to the generic scrollview animation.
withAnimation(.spring()) { // User .spring()
offsetY = nextScrollPosition
}