SwiftUI - How to add foreground linear gradient on image - ios

I am unable to find any related documentation on how to do a linear gradient on the foreground for an image I have with SwiftUI.
I have tried to do it like so:
Image("IconLoseWeight")
.frame(width: 30.0, height: 30.0)
.padding(.leading, 17)
.foregroundColor(LinearGradient(gradient: Gradient(colors: [.white, .black]), startPoint: .top, endPoint: .bottom))
Actually, the code shown above doesn't display any errors, but it breaks the code with warnings that make no sense in the top level Stacks (which I think is a bug with Xcode or SwiftUI). If I remove the foreground modifier, the code runs perfectly.

That's because foregroundColor wants a Color, but LinearGradient is a struct that conforms to the protocols ShapeStyle and View.
If I understand you correctly you want to fill the intransparent area of an image with a gradient?
ZStack {
Color.white // For the background. If you don't need a background, you don't need the ZStack.
LinearGradient(gradient: Gradient(colors: [.green, .blue]), startPoint: .top, endPoint: .bottom)
.mask(Image("AssetWithTransparency")
.resizable()
.padding()
.aspectRatio(contentMode: .fit))
}.cornerRadius(15)
The result looks like this:

The task here is to display gradient over an image. To display one view over another SwiftUI provides ZStack view, so, the code can have the next structure:
ZStack {
<Image>
<Rectangle with gradient>
}
Additionally, to make sure the image we use is resized correctly to the specified frame resizable modifier should be applied with correct contentMode:
Image("IconLoseWeight")
.resizable() // Make it resizable
.aspectRatio(contentMode: .fit) // Specifying the resizing mode so that image scaled correctly
After all, we need to apply frame and padding parameter to ZStack so that gradient has the same size as the image.
The result would look like that:
ZStack {
Image("IconLoseWeight")
.resizable() // Making the image resizable to the container size
.aspectRatio(contentMode: .fit) // Setting up resizing mode so that the image scaled correctly
Rectangle() // Shapes are resizable by default
.foregroundColor(.clear) // Making rectangle transparent
.background(LinearGradient(gradient: Gradient(colors: [.clear, .black]), startPoint: .top, endPoint: .bottom), cornerRadius: 0)
// Specifying gradient (note that one color is .clear)
}
.frame(width: 30, height: 30) // Applying frame
.padding(.leading, 17) // Applying padding
Note, that we use a gradient from .clear to .black as we need a transparent gradient to make the image visible.

Agree with #RyuX51's answer and it's working well. But some how size and alignment of my image got changed. Because LinearGradient's frame isn't set. So here i came up with the solution for just applying gradient to the Image,
VStack{
Spacer()
Button(action: {
print("Add Photos")
}, label: {
LinearGradient(gradient: Gradient(colors: [.green, .blue]), startPoint: .top, endPoint: .bottom)
.mask(Image(systemName: "plus.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
).frame(width: 70, height: 70, alignment: .center)
})
}

The best way to do this as of the most recent SwiftUI release would be to use the .foregroundStyle() view modifier. I'm not sure when this approach became available but this code was tested with Xcode 14 and iOS 16.
Sample code below:
let gradient = Gradient(colors: [.purple, .cyan, .orange])
var body: some View {
Image(systemName: "figure.strengthtraining.traditional")
.font(.title)
.foregroundStyle(.linearGradient(gradient, startPoint: .top, endPoint: .bottom))
}

RyuX51's answer worked for me except the view ended up stretching to fill the available space. I wanted it to shrink to fit the size of the image I was using.
This worked well for my needs:
extension View {
/// Fills in self with `fill`, maintaining self's natural size
/// - Parameter fill: View to fill in self with (i.e., a gradient)
/// - Returns: Filled-in version of self
#ViewBuilder func filled(with fill: () -> some View) -> some View {
self.overlay {
fill().mask { self }
}
}
}
So for example:
Image(systemName: "car.2.fill")
.font(.system(size: 75))
.imageScale(.large)
.aspectRatio(contentMode: .fit)
.filled {
LinearGradient(
gradient: Gradient(colors: [.green, .blue]), startPoint: .top, endPoint: .bottom)
}
.border(.orange, width: 2)
will give you:

Related

How to resize a rectangle to fit a device in SwiftUI

I have a rectangle in a frame as shown below
Rectangle in iPhone 13
Rectangle in iPhone 8
I am trying to proportionally adjust the height of rectangle so that there is an equal proportion of white in every iPhone model, but as is clearly visible the rectangle takes over the entire screen in a smaller iPhone model.
I am sure SwiftUI has a formal feature that allows for the rectangle to be resized proportionally.
How can I do this? Code shown below
VStack {
ZStack {
//Rectangle 12
Rectangle()
.fill(Color(#colorLiteral(red: 0.7137255072593689, green: 0.10196077823638916, blue: 0.10196077823638916, alpha: 1)))
.frame(width: 391, height: 800)
}
Spacer()
}.ignoresSafeArea(edges: .top)
EDIT: It seems like my question, while describing what I want in isolation did not truly encapsulate what I want
I want to create this design
enter image description here
However, I want it to scale properly across devices.
How would I go about doing this?
This is exactly what a GeometryReader is for. It reads the size given to it, in this case the whole screen. Then, you can put a .frame() on the Spacer which is a compressible view to keep it at a set size. In the example code below, it is 10% of the view height.
struct ProportionalRectangle: View {
var body: some View {
GeometryReader { geometry in
VStack {
Rectangle()
.fill(Color(#colorLiteral(red: 0.7137255072593689, green: 0.10196077823638916, blue: 0.10196077823638916, alpha: 1)))
Spacer()
.frame(height: geometry.size.height * 0.1)
}
}
.ignoresSafeArea()
}
}
Do you mean you want to divide the view into red rectangle and white.
struct ContentView: View {
var body: some View {
VStack {
Rectangle()
.fill(.red)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Rectangle()
.fill(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}.ignoresSafeArea()
}
}

SwiftUI: stretch LinearGradient on a rectangle

I am trying to render a linear gradient on a rectangle. Here is the code:
Rectangle()
.foregroundColor(Color.red)
.overlay(
LinearGradient(gradient: Gradient(colors: [.red, .yellow, .blue]), startPoint: .topLeading, endPoint: .bottomTrailing)
)
.frame(width: 300, height: 200)
.position(x: 170, y: 120)
When I render it on square, everything looks correct:
When I render it on a rectangle, however, it stops looking like it's going from topLeading corner to bottomTrailing. It just looks like it's the same one which was clipped:
Here is how it's rendered in svg (blue is not the same it seems, but that's not the important part), and how I want it to look in swift:
The yellow diagonal should go directly from one corner to another, since I am specifying startPoint: .topLeading, endPoint: .bottomTrailing. It says here SwiftUI diagonal LinearGradient in a rectangle that this is a standard behaviour, and that's ok - I'm not trying to say SwiftUI renders it incorrectly, I just need a way to make it look like in svg.
What a fun problem :)
The "naive" approach would be to render the gradient as a square and then either rotate it or squeeze or stretch it to fit the rectangle. Rotating requires more maths so here's the "squeezing" version:
struct ContentView: View {
var body: some View {
Rectangle()
.overlay(
GeometryReader { g in
LinearGradient(
gradient: Gradient(colors: [.red, .yellow, .blue]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: g.size.width, height: g.size.width)
.scaleEffect(x: 1.0, y: g.size.height / g.size.width, anchor: .top)
}
)
.frame(width: 300, height: 200)
}
}
Notes:
To do the squeeze, we need to know the proportions of the rectangle, you were already using an overlay() and overlays are bounded by the Rectangle() in this case, so we can just read the size with the GeometryReader();
We need to start with the gradient applied to a square and there is a couple of ways to do squares. As we already know the size of the rectangle, I went with a square with a side equal to the width of the enclosing rectangle;
Then we apply the scaleEffect(), squeezing the height of the square to the height of the rectangle.
Please let me know if that's what you were looking for or if there is a better way to do to this!
Cheers,
–Baglan

How to center crop an image in SwiftUI

I'm new to SwiftUI. I guess everyone is at this point. I've been an app developer for about 6 years now and I feel stupid asking this question on StackOverflow. But I looked everywhere. How do I center crop an image in an ImageView in SwiftUI?
I know there's an option to change the aspect ratio but I only see fit and fill. I just want the imageView to centerCrop(android term) the image. Does anybody know?
Android's ImageView.ScaleType documentation describes CENTER_CROP as:
CENTER_CROP
Scale the image uniformly (maintain the image's aspect ratio) so that
both dimensions (width and height) of the image will be equal to or
larger than the corresponding dimension of the view (minus padding).
The image is then centered in the view.
This is essentially what Aspect Fill Scaling (aka .scaledToFill()) does, except (surprisingly) Aspect Fill doesn't clip the parts that fall outside of the frame.
By making the image .resizable, and applying .scaledToFill(). the image will be scaled proportionally to fill its available frame leaving off the top and bottom or sides as necessary. .clipped() then removes the parts of the image outside of the frame.
Image("myImage")
.resizable()
.scaledToFill()
.frame(width: 200, height: 200, alignment: .center)
.clipped()
To make this more convenient, I created this extension of Image:
extension Image {
func centerCropped() -> some View {
GeometryReader { geo in
self
.resizable()
.scaledToFill()
.frame(width: geo.size.width, height: geo.size.height)
.clipped()
}
}
}
To use the Image extension, just put it in a file in your project (a name like image-centercropped.swift will work nicely). Then just add .centerCropped() to any image you want to be center cropped.
Image("apolloimage").centerCropped()
It uses GeometryReader to figure out its frame so that it can crop the image correctly, which means you don't have to specify the frame to get proper clipping. You are free to size the image however you like using an explicit frame, or by just adding padding() and Spacer() to keep it nicely placed relative to other user interface items.
For example: If you want an image to fill the screen of the phone:
struct ContentView: View {
var body: some View {
Image("apolloimage")
.centerCropped()
.edgesIgnoringSafeArea(.all)
}
}
will nicely show the center of the image by scaling the image to show either the full height or the full width of the image and cropping the parts the hang over on the other dimension.
Demonstration:
Here's a demo that shows how the image is centered and cropped as the image grows. In this demo, the frame width is a constant 360 while the frame height varies from 50 to 700 as the slider advances to the right. At the beginning when the frame is short, the tops and bottoms of the image are cropped. As the frame exceeds the aspectRatio of the original image, the resulting image is centered but cropped on the left and right sides.
struct ContentView: View {
#State private var frameheight: CGFloat = 50
var body: some View {
VStack(spacing: 20) {
Spacer()
Image("apolloimage")
.resizable()
.scaledToFill()
.frame(width: 360, height: self.frameheight)
.clipped()
Spacer()
Slider(value: self.$frameheight, in: 50...700)
.padding(.horizontal, 20)
}
}
}
or an equivalent test using .centerCropped():
struct ContentView: View {
#State private var frameheight: CGFloat = 50
var body: some View {
VStack(spacing: 20) {
Spacer()
Image("apolloimage")
.centerCropped()
.frame(width: 360, height: self.frameheight)
Spacer()
Slider(value: self.$frameheight, in: 50...700)
.padding(.horizontal, 20)
}
}
}
Alternate Solution
Another way to make a center cropped image is to make the image an .overlay() of Color.clear. This allows Color.clear to establish the clipping bounds.
Color.clear
.overlay(
Image("apolloimage")
.resizable()
.scaledToFill()
)
.clipped()
and the corresponding extension to Image looks like this:
extension Image {
func centerCropped() -> some View {
Color.clear
.overlay(
self
.resizable()
.scaledToFill()
)
.clipped()
}
}
I was able to crop the square centre of image for view like iPhone Photo app.
extension Image {
func centerSquareCropped() -> some View {
GeometryReader { geo in
let length = geo.size.width > geo.size.height ? geo.size.height : geo.size.width
self
.resizable()
.scaledToFill()
.frame(width: length, height: length, alignment: .center)
.clipped()
}
}
}
I was originally using #vacawama's answer for this which uses the GeometryReader but discovered that actually this isn't necessary.
(I'm writing this with Xcode 13 and running in iOS15 if that makes any difference?)
Using this is sufficient...
Image(uiImage: image) // insert your own image here :D
.resizable()
.aspectRatio(contentMode: .fill)
.clipped()
I'm using this as the label parameter of a Button inside a List so the whole thing is like...
Section("Photo") {
Button {
// the action
} label: {
if let image = viewStore.imagePickerState.image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.clipped()
} else {
PersonAvatarButton()
}
}
}
.aspectRatio(1, contentMode: .fill)
.listRowBackground(Color.gray)
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
I haven't defined the frame anywhere for this, just the aspect ratio of the Section.
What I end up with is a square button with rounded corners and the photo all the way to the edge. Resized to fill but not squashed in any way. And the button size is determined by the screen size.
So I don't have any concrete sizes in my code.

SwiftUI ScrollView BlendMode Issues

I have run into an issue with SwiftUI and ScrollView. When I have a subview with a blend mode attribute in a scrollview, the blend does not affect what is behind the scrollview. But if I put the same item in a Stack, it does affect the background.
I tried to apply the blend mode directly to the scrollview, but since there are other items in the subview that are not blended, it messes that up.
Here is a sample to illustrate the issue. with this, you can see the gradient blend works in the Stack but not in the scrollview.
let mode: BlendMode = .overlay
func item(_ num: Int) -> some View {
return AnyView(
ZStack {
Rectangle()
.fill( LinearGradient(gradient: Gradient(colors: [Color.blue, Color.black]), startPoint: .top, endPoint: .bottom))
.frame(width: 100, height: 100)
.blendMode(mode)
Text("Number = \(num)")
.font(.headline)
.foregroundColor(Color.white)
}
)
}
struct ContentView: View {
var body: some View {
ZStack {
Rectangle()
.fill(RadialGradient(gradient: Gradient(colors: [Color.black, Color.red]), center: .topLeading, startRadius: 200, endRadius: 800))
HStack (spacing: 50) {
VStack (spacing: 50) {
ForEach((1...5).reversed(), id: \.self) {
item($0)
}
}
ScrollView {
VStack (spacing: 50) {
ForEach((1...5).reversed(), id: \.self) {
item($0)
}
}
}
//.blendMode(mode)
/*
blendMode for item() in ScrollView does not work.
Uncomment blendMode modifier above to get gradient blend, but text is not correct
*/
}
}
}
}
I have also tried to apply the black-to-red Rectangle as a background of the Scrollview, and that doesn't fix it, either.
ScrollView {
VStack (spacing: 50) {
ForEach((1...5).reversed(), id: \.self) {
item($0)
}
}
}
.background(
Rectangle()
.fill(RadialGradient(gradient: Gradient(colors: [Color.black, Color.red]), center: .topLeading, startRadius: 200, endRadius: 800))
)
In the pictures below, the look I want is in the left VStack.
No blend mode modifier on ScrollView
Blend mode modifier applied to Scrollview
Any idea how to get around this?
Only thing I could think, which seems very hacky, was to basically create two scrollviews z-stacked with blend applied to only the scrollview needing it and then put the items not blended in the topmost scrollview.
However, I don't think that is viable as I see no way to sync scrolling across the two scrollviews.
I've come across a similar issue and from experimenting it seems as though the blend modes are affected when the SwiftUI View with the blend mode set is directly inside a UIHostingView (such as a ScrollView).
You can get the correct blending by placing another SwiftUI view below the one with the blend mode inside the scroll view, e.g.
ScrollView {
ZStack {
Rectangle()
.fill(RadialGradient(gradient: Gradient(colors: [Color.black, Color.red]), center: .topLeading, startRadius: 200, endRadius: 800))
VStack (spacing: 50) {
ForEach((1...5).reversed(), id: \.self) {
item($0)
}
}
}
}
This doesn't quite fix the issue in your example as the two backgrounds are not quite aligned, but in a case where the scroll view takes up the screen it might help.
It seems that adding a .background to the scroll view adds it below the ScrollView rather than at the bottom of the hierarchy in the ScrollView which is why that doesn't seem to fix it.
My issue was in a List row (which is backed by a UITableView), and setting an explicit background on the item inside the List helped there. Hopefully this will point you/others in the right direction.

Clip image to square in SwiftUI

I am trying to put multiple cells next to each other where each cell consists of an image and a text below. The cell itself should be a square and the image should be scaled to fill the remaining space (cutting a part of the image).
First I tried just making the image square and the text below.
Now my problem is, that I don't know how to properly achieve that in SwiftUI. I can get it to work, when using this code:
VStack {
Image(uiImage: recipe.image!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 200, height: 200, alignment: .center)
.clipped()
Text(recipe.name)
}
The problem is, that I have to specify a fixed frame size. What I want is a way to make a cell, that keeps an aspect ratio of 1:1 and is resizable, so I can fit a dynamic amount of them on a screen next to each other.
I also tried using
VStack {
Image(uiImage: recipe.image!)
.resizable()
.aspectRatio(1.0, contentMode: .fit)
.clipped()
Text(recipe.name)
}
which gives me square images, that scale dynamically. But the problem is, that the image now gets stretched to fill the square and not scaled to fill it.
My next idea was to clip it to a square shape. For that I first tried to clip it into a circle shape (because apparently there is not square shape):
VStack {
Image(uiImage: recipe.image!)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(Circle())
Text(recipe.name)
}
But for some odd reason, it didn't really clip the image but instead kept the remaining space...
So am I not seeing something or is the only option to clip an image square the frame modifier?
EDIT
To clarify: I don't care about the text as much, as about the whole cell (or if it's simpler the image) being square, without having to specify its size via .frame and without the non-square original image being stretched to make it fit.
So the perfect solution would be that the VStack is square but getting a square image would also be okay. It should look like Image 1, but without having to use the .frame modifier.
A ZStack will help solve this by allowing us to layer views without one effecting the layout of the other.
For the text:
.frame(minWidth: 0, maxWidth: .infinity) to expand the text horizontally to its parent's size
.frame(minHeight: 0, maxHeight: .infinity) is useful in other situations
As for the image:
.aspectRatio(contentMode: .fill) to make the image maintain its aspect ratio rather than squashing to the size of its frame.
.layoutPriority(-1) to de-prioritize laying out the image to prevent it from expanding its parent (the ZStack within the ForEach in our case).
The value for layoutPriority just needs to be lower than the parent views which will be set to 0 by default. We have to do this because SwiftUI will layout a child before its parent, and the parent has to deal with the child size unless we manually prioritize differently.
The .clipped() modifier uses the bounding frame to mask the view so you'll need to set it to clip any images that aren't already 1:1 aspect ratio.
var body: some View {
HStack {
ForEach(0..<3, id: \.self) { index in
ZStack {
Image(systemName: "doc.plaintext")
.resizable()
.aspectRatio(contentMode: .fill)
.layoutPriority(-1)
VStack {
Spacer()
Text("yes")
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color.white)
}
}
.clipped()
.aspectRatio(1, contentMode: .fit)
.border(Color.red)
}
}
}
Edit: While geometry readers are super useful I think they should be avoided whenever possible. It's cleaner to let SwiftUI do the work. This is my initial solution with a Geometry Reader that works just as well.
HStack {
ForEach(0..<3, id: \.self) { index in
ZStack {
GeometryReader { proxy in
Image(systemName: "pencil")
.resizable()
.scaledToFill()
.frame(width: proxy.size.width)
VStack {
Spacer()
Text("yes")
.frame(width: proxy.size.width)
.background(Color.white)
}
}
}
.clipped()
.aspectRatio(1, contentMode: .fit)
.border(Color.red)
}
}
Here's another solution I found on Reddit and improved a bit:
Color.clear
.aspectRatio(1, contentMode: .fit)
.overlay(
Image(imageName)
.resizable()
.scaledToFill()
)
.clipShape(Rectangle())
It is similar to Chads answer but differs in the way you put image relatively to the clear color (background vs overlay)
Bonus: to let it have circular shape just use .clipShape(Circle()) as the last modifier. Everything else stays unchanged
This is the answer that worked for me:
VStack {
Color.clear
.aspectRatio(1, contentMode: .fit)
.background(Image(uiImage: recipe.image!)
.resizable()
.aspectRatio(contentMode: .fill))
.clipped()
Text(recipe.name)
}
I use clear color, then set the aspect ratio using fit. That makes the container square. Then I added a background of the image set it to fill. To top it off, I add clipped so the background doesn't spill over the edges of the square.
Answer based on the one by #ramzesenok but wrapped into a view modifier
Modifier:
struct FitToAspectRatio: ViewModifier {
let aspectRatio: Double
let contentMode: SwiftUI.ContentMode
func body(content: Content) -> some View {
Color.clear
.aspectRatio(aspectRatio, contentMode: .fit)
.overlay(
content.aspectRatio(nil, contentMode: contentMode)
)
.clipShape(Rectangle())
}
}
You can optionally also add an extension function for easy access
extension Image {
func fitToAspect(_ aspectRatio: Double, contentMode: SwiftUI.ContentMode) -> some View {
self.resizable()
.scaledToFill()
.modifier(FitToAspectRatio(aspectRatio: aspectRatio, contentMode: contentMode))
}
}
and then simply
Image(...).fitToAspect(1, contentMode: .fill)
It works for me, but I don't know why cornerRadius is necessary...
import SwiftUI
struct ClippedImage: View {
let imageName: String
let width: CGFloat
let height: CGFloat
init(_ imageName: String, width: CGFloat, height: CGFloat) {
self.imageName = imageName
self.width = width
self.height = height
}
var body: some View {
ZStack {
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: width, height: height)
}
.cornerRadius(0) // Necessary for working
.frame(width: width, height: height)
}
}
struct ClippedImage_Previews: PreviewProvider {
static var previews: some View {
ClippedImage("dishLarge1", width: 100, height: 100)
}
}
GeometryReader for frame
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 1) {
ForEach(recipes) { recipe in
GalleryView(width: geometry.size.width, recipe: recipe)
}
}
}
.edgesIgnoringSafeArea(.all)
}
}
Overlapping gestures
If you have gesture actions and images close each other, add .contentShape() modifier to assign the tappable area.
Width and height get from GeometryReader of the previous View.
struct GalleryView: View {
var width: CGFloat
var recipe: Recipe
private enum C {
static let goldenRatio = CGFloat(0.67)
}
var body: some View {
VStack(spacing: 1) {
Image(uiImage: recipe.image)
.resizable()
.scaledToFill()
.frame(width: width, height: height, alignment: .center)
.clipped()
.contentShape(Rectangle())
Text(recipe.name)
}
.frame(height: width * C.goldenRatio + 1)
.frame(width: width * C.goldenRatio / 2)
}
}

Resources