Is there a way to use string interpolation with an SF Symbol that has a modifier on it? - swift

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.

Related

SwiftUI center align a view except if another view "pushes" it up

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)
}
}
}

How to rescale PKDrawing in SwiftUI on screen rotation?

Im currently making an app usig PencilKit and SwiftUI. If the screen is rotated, the canvas (and the drawing) should be rescaled acordingly. I do this with the Geometry reader and calculating the size.
NavigationView {
GeometryReader { g in
HStack {
CanvasView(canvasView: $canvasView)
.frame(width: g.size.width/1.5, height: (g.size.width/1.5)/1.5)
placeholder(placeholder: scaleDrawing(canvasHeight: (g.size.width/1.5)/1.5))
}
}
}
func scaleDrawing(canvasHeight : CGFloat) -> Bool {
let factor = canvasHeight / lastCanvasHeight
let transform = CGAffineTransform(scaleX: factor, y: factor)
canvasView.drawing = canvasView.drawing.transformed(using: transform)
lastCanvasHeight = canvasHeight
return true
}
The drawing however gets not scaled.
My solution was to create a placeholder view which has a boolean as a parameter. Also a method that scales the drawing with the height of the Canvas as an input.
This works but i think its pretty hacky and not the best solution, but this was the only solution that worked.
Is there a better way to do this? What am i missing?

How to use user input in function in swiftui?

I have a View, in which the user is able to enter some text into a TextField. I want to be able to get the text, which was entered in the TextField and use this value inside of a struct. The concept of the app, is that it shows the elevation degree of the sun. To be able to do this, it is scraping the values from a WebPage. However to make this app dynamic, the user has to be able to edit the url (you can change location, date etc in the url). I thought this would be fairly easy, since I only have to get some text, and edit a url before the url is being loaded. I have been able to pass the value into a view, however I need it in a struct. Maybe the whole "layout of my code is wrong, maybe I should get the data and draw the function in a view? I don't know. This is my first time coding with swift.
I want to change the latitude var.
This is my code:
View 1 (Input):
class ViewModel: ObservableObject {
#Published var latitude:String = ""
#Published var page = 0
}
struct ContentView: View {
#EnvironmentObject var value1: ViewModel
var body: some View {
if value1.page == 0{
VStack{
TextField("", text: $value1.latitude)
Button(action:{ value1.page = 1}){
Text("To next view")
}.frame(width: 300, height: 100, alignment: .center)
}
} else {
elevationGraph()
}
}
}
View 2 (Graph)
struct getHtml {
var url = URL(string: "https://midcdmz.nrel.gov/apps/spa.pl?syear=2020&smonth=1&sday=1&eyear=2020&emonth=1&eday=1&otype=0&step=60&stepunit=1&hr=12&min=0&sec=0&latitude=\(latitude)&longitude=10.757933&timezone=1.0&elev=53&press=835&temp=10&dut1=0.0&deltat=64.797&azmrot=180&slope=0&refract=0.5667&field=0")
func loadData(from url: URL?) -> String {
guard let url = url else {
return "nil"
}
let html = try! String(contentsOf: url, encoding: String.Encoding.utf8)
return html
}
}
struct elevationFunction: Shape {
var url: URL? //This only works in views, is there a way to do it in shape structs?
let html = getHtml.init().loadData(from: getHtml.init().url)
private func dbl1() -> Double {
let leftSideOfTheValue = "0:00:00,"
let rightSideOfTheValue = "\(month)/\(day)/\(year),1:00:00,"
guard let leftRange = html.range(of: leftSideOfTheValue) else {
print("cant find left range")
return 0
}
guard let rightRange = html.range(of: rightSideOfTheValue) else {
print("cant find right range")
return 0
}
let rangeOfTheValue = leftRange.upperBound..<rightRange.lowerBound
return Double(html[rangeOfTheValue].dropLast()) ?? 90
}
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: 10, y: (125 - (90-dbl1()))))
path.addLine(to: CGPoint(x: 120, y: (125 - (90-45))))
path.addLine(to: CGPoint(x: 250, y: (125 - (90-dbl1()))))
var scale = (rect.height / 350) * (9/10)
var xOffset = (rect.width / 6)
var yOffset = (rect.height / 2)
return path.applying(CGAffineTransform(scaleX: scale, y: scale)).applying(CGAffineTransform(translationX: xOffset, y: yOffset))
}
}
struct elevationGraph: View {
var body: some View {
GeometryReader { geometry in
ZStack {
elevationFunction().stroke(LinearGradient(gradient: Gradient(colors: [Color.yellow, Color.red]), startPoint: .top , endPoint: .bottom), style: StrokeStyle(lineWidth: 6.0)).aspectRatio(contentMode: .fill)
}
.frame(width: 600, height: 800, alignment: .center)
}
}
}
As mentioned in my comment, you can pass a parameter to a Shape just like you can a regular View:
elevationFunction(url: yourURL)
Best practice would be to capitalize this and name it ...Shape as well:
elevationFunction becomes ElevationShape
Regarding your second question in the comment, first, you may want to fix the naming of getHtml for the same reason as above -- uncapitalized, it looks like a variable name. Maybe something like DataLoader.
Regarding the crash, you have some circular logic going on -- you call getHtml.init() and then pass a parameter that is again derived from getHtml.init() again. Why not just call getHtml() and have it loadData from its own internal URL property?
There's a larger problem at work, though, which is that you've declared html as a let property on your Shape, which is going to get recreated every time your Shape is rendered. So, on every render, with your current code, you'll create 2 new getHtmls and attempt to load the data (which very well may not actually have time to load the URL request). This very well could be blocking the first render of the Shape as well and is almost certainly causing your crash somewhere in the circular and repetitive logic going on.
Instead, you might want to consider moving your URL request to onAppear or as part of an ObservableObject where you can have a little more control of when and how often this data gets loaded. Here's a good resource on learning more about loading data using URLSession and SwiftUI: https://www.hackingwithswift.com/books/ios-swiftui/sending-and-receiving-codable-data-with-urlsession-and-swiftui

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
}

SwiftUI .rotationEffect() framing and offsetting

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)