How to scale a SwiftUI Image including its overlay? - swift

Given this view with an image that has an overlay:
struct TestView: View {
var body: some View {
Image("image")
.overlay {
Image("logo")
.position(x: 20, y: 130) // I want to freely position the overlay image
}
}
}
The size of the images shouldn’t matter. I my example they are 150×150 pixels (“image”) and 20×20 pixels (logo).
How can I scale this up to its bounds (like superviews frame), including the overlay?
Some must-have conditions:
I need to freely position the overlay image, so I can not use the alignment parameter of overlay in conjunction with a padding on the logo.
I also need to position the overlay image absolutely, not relatively. So I can’t use a GeometryReader (or alignment).
I need the view to be reusable in different scenarios (different devices, different view hierarchies). That means I don’t know the scale factor, so I can’t use scaleEffect.
I tried:
A) .aspectRatio(contentMode: .fit)
struct TestView: View {
var body: some View {
Image("image")
.resizable()
.overlay {
Image("logo")
.position(x: 20, y: 130)
}
.aspectRatio(contentMode: .fit)
}
}
B) .scaledToFit()
struct TestView: View {
var body: some View {
Image("image")
.resizable()
.overlay {
Image("logo")
.position(x: 20, y: 130)
}
.scaledToFit()
}
}
C) Making logo resizable
struct TestView: View {
var body: some View {
Image("image")
.resizable()
.overlay {
Image("logo")
.resizable()
.position(x: 20, y: 130)
}
.scaledToFit() // or .aspectRatio(contentMode: .fit)
}
}
A and B looking like this:
C looking like this:
Both of which gave me a relative position of the logo that was different from the original (see linked screenshot). The relative size differs as well (it is now too small).
Wrapping in a stack and scale this stack instead also didn’t help.
[UPDATE] Accepted answer
Apparently not having a real view hierarchy in SwiftUI vs UIKit comes at a price. Scaling a hierarchy shouldn’t be that complicated imho. Instead I still would expect .scaledToFit (if added at the end) to scale everything before it (see A), B), and C)).
Adapted accepted answer (minus contentSize and alignment: .topLeading):
struct ContentView: View {
var body: some View {
Image("image")
.resizable()
.scaledToFit()
.overlay {
GeometryReader { geometry in
let imageSize = CGSize(width: 150, height: 150)
let logoSize = CGSize(width: 20, height: 20)
let logoPosition = CGPoint(x: 20, y: 130)
Image("logo")
.resizable()
.position(
x: logoPosition.x / imageSize.width * geometry.size.width,
y: logoPosition.y / imageSize.height * geometry.size.height
)
.frame(
width: logoSize.width / imageSize.width * geometry.size.width,
height: logoSize.height / imageSize.height * geometry.size.height
)
}
}
}
}

Then like this with GeometryReader:
struct ContentView: View {
let imageSize = CGSize(width: 150, height: 150) // Size of orig. image
let logoPos = CGSize(width: 10, height: 120) // Position of Logo in relation to orig. image
let logoSize = CGSize(width: 20, height: 20) // Size of logo in relation to orig. image
#State private var contentSize = CGSize.zero
var body: some View {
Image("dog")
.resizable()
.scaledToFit()
.overlay(
GeometryReader { geo in
Color.clear.onAppear {
contentSize = geo.size
}
}
)
.overlay(
Image(systemName: "circle.hexagongrid.circle.fill")
.resizable()
.foregroundColor(.pink)
.offset(x: logoPos.width / imageSize.width * contentSize.width,
y: logoPos.height / imageSize.height * contentSize.height)
.frame(width: logoSize.width / imageSize.width * contentSize.width,
height: logoSize.height / imageSize.height * contentSize.height)
, alignment: .topLeading)
}
}

The problem is the absolute positioning of the logo. I see two ways:
scale the whole resulting image with .scaleEffect.
Be aware this is a render scaling of the underlying image, it will not be redrawn in the new size, so can become blurry.
Image("dog")
.overlay {
Image(systemName: "circle.hexagongrid.circle.fill")
.foregroundColor(.pink)
.position(x: 20, y: 130)
}
.scaleEffect(2.0)
or – and my preference
2. Position the Logo relative to the lower left corner
ZStack(alignment: .bottomLeading) {
Image("dog")
.resizable()
.scaledToFit()
Image(systemName: "circle.hexagongrid.circle.fill")
.resizable()
.foregroundColor(.pink)
.frame(width: 70, height: 70)
.padding()
}

Related

SwiftUI - How to center a component in a horizontal ScrollView when tapped?

I have a horizontal ScrollView, and within it an HStack. It contains multiple Subviews, rendered by a ForEach. I want to make it so that when these Subviews are tapped, they become centered vertically in the view. For example, I have:
ScrollView(.horizontal) {
HStack(alignment: .center) {
Circle() // for demonstration purposes, let's say the subviews are circles
.frame(width: 50, height: 50)
Circle()
.frame(width: 50, height: 50)
Circle()
.frame(width: 50, height: 50)
}
.frame(width: UIScreen.main.bounds.size.width, alignment: .center)
}
I tried this code:
ScrollViewReader { scrollProxy in
ScrollView(.horizontal) {
HStack(alignment: .center) {
Circle()
.frame(width: 50, height: 50)
.id("someID3")
.frame(width: 50, height: 50)
.onTapGesture {
scrollProxy.scrollTo(item.id, anchor: .center)
}
Circle()
.frame(width: 50, height: 50)
.id("someID3")
.frame(width: 50, height: 50)
.onTapGesture {
scrollProxy.scrollTo(item.id, anchor: .center)
}
...
}
}
But it seemingly had no effect. Does anyone know how I can properly do this?
You can definitely do this with ScrollView and ScrollViewReader. However, I see a couple of things that could cause problems in your code sample:
You use the same id "someID3" twice.
I can't see where your item.id comes from, so I can't tell if it actually contains the same id ("someID3").
I don't know why you have two frames with the same bounds on the same view area. It shouldn't be a problem, but it's always best to keep things simple.
Here's a working example:
import SwiftUI
#main
struct MentalHealthLoggerApp: App {
var body: some Scene {
WindowGroup {
ScrollViewReader { scrollProxy in
ScrollView(.horizontal) {
HStack(alignment: .center, spacing: 10) {
Color.clear
.frame(width: (UIScreen.main.bounds.size.width - 70) / 2.0)
ForEach(Array(0..<10), id: \.self) { id in
ZStack(alignment: .center) {
Circle()
.foregroundColor(.primary.opacity(Double(id)/10.0))
Text("\(id)")
}
.frame(width: 50, height: 50)
.onTapGesture {
withAnimation {
scrollProxy.scrollTo(id, anchor: .center)
}
}
.id(id)
}
Color.clear
.frame(width: (UIScreen.main.bounds.size.width - 70) / 2.0)
}
}
}
}
}
}
Here you can see it in action:
[EDIT: You might have to click on it if the GIF won't play automatically.]
Note that I added some empty space to both ends of the ScrollView, so it's actually possible to center the first and last elements as ScrollViewProxy will never scroll beyond limits.
Created a custom ScrollingHStack and using geometry reader and a bit calculation, here is what we have:
struct ContentView: View {
var body: some View {
ScrollingHStack(space: 10, height: 50)
}
}
struct ScrollingHStack: View {
var space: CGFloat
var height: CGFloat
var colors: [Color] = [.blue, .green, .yellow]
#State var dragOffset = CGSize.zero
var body: some View {
GeometryReader { geometry in
HStack(spacing: space) {
ForEach(0..<15, id: \.self) { index in
Circle()
.fill(colors[index % 3])
.frame(width: height, height: height)
.overlay(Text("\(Int(dragOffset.width))"))
.onAppear {
dragOffset.width = geometry.size.width / 2 - ((height + space) / 2)
}
.onTapGesture {
let totalItems = height * CGFloat(index)
let totalspace = space * CGFloat(index)
withAnimation {
dragOffset.width = (geometry.size.width / 2) - (totalItems + totalspace) - ((height + space) / 2)
}
}
}
}
.offset(x: dragOffset.width)
.gesture(DragGesture()
.onChanged({ dragOffset = $0.translation})
)
}
}
}

How we can put image button below corner of circle image in SwiftUI?

I have circle image, and I want to put pencil image button like below in image, I try to use
.padding(.vertical, 40) .padding(.horizontal,20)
or
.padding(.top,40)
.padding(.leading,20)
but it is not work. Any idea?
ZStack{
Image("image")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 120, height: 120)
.clipShape(Circle())
Button() {
} label: {
Image("pencil")
}
}
You could achieve the layout by calculating the right offset for the camera Button as you want the button to be placed on the edge of the circle:
let radius: CGFloat = 100
var offset: CGFloat {
sqrt(radius * radius / 2)
}
var body: some View {
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(
width: radius * 2,
height: radius * 2)
.clipShape(Circle())
.overlay(
Button(action: {}) {
Image(systemName: "camera.fill")
.foregroundColor(.primary)
.padding(8)
.background(Color.gray)
.clipShape(Circle())
.background(
Circle()
.stroke(Color.white, lineWidth: 2)
)
}.offset(x: offset, y: offset)
)
}
gives a result like this:
You could obviously either hardcode the radius of the circle or pass it into the initialiser of this View if needed.
Set alignment of ZStack
ZStack(alignment: .bottomTrailing){ //<-- Here
Image("image")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 120, height: 120)
.clipShape(Circle())
Button() {
} label: {
Image("pencil")
}
}

How to present accurate star rating using SwiftUI?

I need to present accurate star rating like 3.1, 4.8 using SwiftUI.
The desired result should be like this:
Your general approach is good, but I believe it can be made much simpler.
The below code adapts to whatever size it is placed in (so if you want a specific size, put it in a frame).
Note that the internal ZStack isn't required in iOS 14, but GeometryReader still doesn't document its layout behavior (except in an Xcode 12 release note), so this makes it explicit.
struct StarsView: View {
var rating: CGFloat
var maxRating: Int
var body: some View {
let stars = HStack(spacing: 0) {
ForEach(0..<maxRating, id: \.self) { _ in
Image(systemName: "star.fill")
.resizable()
.aspectRatio(contentMode: .fit)
}
}
stars.overlay(
GeometryReader { g in
let width = rating / CGFloat(maxRating) * g.size.width
ZStack(alignment: .leading) {
Rectangle()
.frame(width: width)
.foregroundColor(.yellow)
}
}
.mask(stars)
)
.foregroundColor(.gray)
}
}
This draws all the stars in gray, and then creates a yellow rectangle of the correct width, masks it to the stars, and draws that on top as an overlay. Overlays are automatically the same size as the view they're attached to, so you don't need all the frames to make the sizes match they way you do with a ZStack.
After spending some time I did found a solution.
struct StarsView: View {
let rating: CGFloat
let maxRating: CGFloat
private let size: CGFloat = 12
var body: some View {
let text = HStack(spacing: 0) {
Image(systemName: "star.fill")
.resizable()
.frame(width: size, height: size, alignment: .center)
Image(systemName: "star.fill")
.resizable()
.frame(width: size, height: size, alignment: .center)
Image(systemName: "star.fill")
.resizable()
.frame(width: size, height: size, alignment: .center)
Image(systemName: "star.fill")
.resizable()
.frame(width: size, height: size, alignment: .center)
Image(systemName: "star.fill")
.resizable()
.frame(width: size, height: size, alignment: .center)
}
ZStack {
text
HStack(content: {
GeometryReader(content: { geometry in
HStack(spacing: 0, content: {
let width1 = self.valueForWidth(geometry.size.width, value: rating)
let width2 = self.valueForWidth(geometry.size.width, value: (maxRating - rating))
Rectangle()
.frame(width: width1, height: geometry.size.height, alignment: .center)
.foregroundColor(.yellow)
Rectangle()
.frame(width: width2, height: geometry.size.height, alignment: .center)
.foregroundColor(.gray)
})
})
.frame(width: size * maxRating, height: size, alignment: .trailing)
})
.mask(
text
)
}
.frame(width: size * maxRating, height: size, alignment: .leading)
}
func valueForWidth(_ width: CGFloat, value: CGFloat) -> CGFloat {
value * width / maxRating
}
}
Usage:
StarsView(rating: 2.4, maxRating: 5)
Another simple solution that enables star fill level through clipping. You can use it with following API:
/// Pass in rating as a Binding or provide a constant
StarRating(rating: .constant(1.6), maxRating: 5)
.font(.title2) // Provide font for sizing
Which looks like:
Implementation:
struct StarRating: View {
struct ClipShape: Shape {
let width: Double
func path(in rect: CGRect) -> Path {
Path(CGRect(x: rect.minX, y: rect.minY, width: width, height: rect.height))
}
}
#Binding var rating: Double
let maxRating: Int
init(rating: Binding<Double>, maxRating: Int) {
self.maxRating = maxRating
self._rating = rating
}
var body: some View {
HStack(spacing: 0) {
ForEach(0..<maxRating, id: \.self) { _ in
Text(Image(systemName: "star"))
.foregroundColor(.blue)
.aspectRatio(contentMode: .fill)
}
}.overlay(
GeometryReader { reader in
HStack(spacing: 0) {
ForEach(0..<maxRating, id: \.self) { _ in
Image(systemName: "star.fill")
.foregroundColor(.blue)
.aspectRatio(contentMode: .fit)
}
}
.clipShape(
ClipShape(width: (reader.size.width / CGFloat(maxRating)) * CGFloat(rating))
)
}
)
}
}

SwiftUI: How to find the height of an image and use it to set the size of a frame

So I've been trying to figure out how to find the height of an image in SwiftUI for a while now with no success. Essentially I have portrait/landscape images, which the portrait ones I want to display at about 600 pt tall, whereas the landscape images I want to display so the width of the image is used, so the height is whatever. That being said, I'm wanting it inside of a GeometryReader, as the offset I am using uses it so that when you scroll down, the image doesn't scroll, but if you scroll up, it does. I don't believe I can share images, but in any case, this should be enough code to run it successfully!
struct ContentView: View {
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
var backButton: some View {
Button(action: {
withAnimation {
self.mode.wrappedValue.dismiss()
}
}) {
Image(systemName: "chevron.left.circle.fill")
.opacity(0.8)
.font(.system(size: 30))
.foregroundColor(.black)
.background(Color.gray.mask(Image(systemName: "chevron.left").offset(x: 9.4, y: 7.6)))
}
}
var body: some View {
ScrollView(showsIndicators: false) {
GeometryReader { geometry in
Image("image1")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: UIScreen.main.bounds.width, minHeight: 350, maxHeight: 600, alignment: .top)
.clipped()
.overlay(
Rectangle()
.foregroundColor(.clear)
.background(LinearGradient(gradient: Gradient(colors: [.clear, .white]), startPoint: .center, endPoint: .bottom))
)
.offset(y: geometry.frame(in: .global).minY > 0 ? -geometry.frame(in: .global).minY : 0)
}
.frame(minWidth: UIScreen.main.bounds.width, maxWidth: UIScreen.main.bounds.width, minHeight: 350, idealHeight: 600, maxHeight: 600)
//Message
VStack(alignment: .leading) {
Text("\(07/22/20) - Day \(1)")
.font(.body)
.fontWeight(.light)
.foregroundColor(Color.gray)
.padding(.leading)
Text("what I Love)
.font(.title)
.fontWeight(.bold)
.padding([.leading, .bottom])
Text("paragraph")
.padding([.leading, .bottom, .trailing])
}
}
.edgesIgnoringSafeArea(.all)
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading: backButton)
}
}
Any help would be much appreciated!
EDIT: Here are two images for reference- Sorry I didn't realize you were able to, though I should've figured that!
If I understood correctly, you want the size of the image so you can use it's dimensions in it's own frame? You could make a function that takes in an image name and returns your desired image while also setting #State vars for the width and height which you can use in the frame.
#State var width: CGFloat = 0
#State var height: CGFloat = 0
func image(_ name: String) -> UIImage {
let image = UIImage(named: name)!
let width = image.size.width
let height = image.size.height
DispatchQueue.main.async {
if width > height {
// Landscape image
// Use screen width if < than image width
self.width = width > UIScreen.main.bounds.width ? UIScreen.main.bounds.width : width
// Scale height
self.height = self.width/width * height
} else {
// Portrait
// Use 600 if image height > 600
self.height = height > 600 ? 600 : height
// Scale width
self.width = self.height/height * width
}
}
return image
}
Usage:
GeometryReader { geometry in
Image(uiImage: image("image1"))
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: width, height: height)
.overlay(
Rectangle()
.foregroundColor(.clear)
.background(LinearGradient(gradient: Gradient(colors: [.clear, .white]), startPoint: .center, endPoint: .bottom))
)
.offset(y: geometry.frame(in: .global).minY > 0 ? -geometry.frame(in: .global).minY : 0)
edit: Added the DispatchQueue.main.async block as a quick fix for 'Modifying state during view update' warning. Works but probably not the absolute best way to go about it. Might be better to put the image into it's own #State and set it in the view's onAppear method.
edit 2: Moved the image into it's own #State var which I believe is a better solution as it will make the view more reusable as you can pass an image instead of hard coding the name of the image in the view.
#State var image: UIImage {
didSet {
let width = image.size.width
let height = image.size.height
DispatchQueue.main.async {
if width > height {
// Landscape image
self.width = width > UIScreen.main.bounds.width ? UIScreen.main.bounds.width : width
self.height = self.width/width * height
} else {
self.height = height > 600 ? 600 : height
self.width = self.height/height * width
}
}
}
}
Usage:
GeometryReader { geometry in
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: width, height: height)
.overlay(
Rectangle()
.foregroundColor(.clear)
.background(LinearGradient(gradient: Gradient(colors: [.clear, .white]), startPoint: .center, endPoint: .bottom))
)
.offset(y: geometry.frame(in: .global).minY > 0 ? -geometry.frame(in: .global).minY : 0)
Will have to update how the ContentView is declared by passing in a UIImage:
ContentView(image: UIImage(named: "image0")!)

How to access parents frame in SwiftUI

Still working on SwiftUI, but I got a problem.
I created an Image with a dynamic frame, and I want a label which needs to be half width of the image (e.g Image width = 300; Label width 150)
In UIKit I should code something like this:
Label.widthAnchor.constraint(image.widthAnchor, multiplier: 1/2).isActive = true
I tried something similar in SwiftUI:
Image(systemName: "j.circle.fill")
.resizable()
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width)
Text("Placeholder").frame(width: Image.frame.width/2, height: 30)
But this doesn't work.
What shall I do?
Why not setting hard sizes for both then :
Image(systemName: "j.circle.fill")
.resizable()
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width)
Text("Placeholder")
.frame(width: UIScreen.main.bounds.width/2, height: 30)
If you want to change the whole size of the component in one place you could just do it like this :
struct MyView : View {
private var compWidth: CGFloat { UIScreen.main.bounds.width }
var body: some View {
VStack {
Image(systemName: "j.circle.fill")
.resizable()
.frame(width: compWidth, height: compWidth)
Text("Placeholder")
.frame(width: compWidth/2, height: 30)
}
}
}
Or, for a more elegant approach you could look into GeometryReader.