SwiftUI: How to constrain image size? - swiftui

I am trying to constrain image size between min and max. But the view expands both width and height to their max values, removing the original aspect ratio.
import SwiftUI
struct ImageConstrainSizeTest: View {
var body: some View {
Image("bike")
.resizable()
.aspectRatio(contentMode: .fit)
.border(Color.yellow, width: 5)
.frame(minWidth: 10, maxWidth: 300, minHeight: 10, maxHeight: 300, alignment: .center)
.border(Color.red, width: 5)
}
}
struct ImageConstrainSizeTest_Previews: PreviewProvider {
static var previews: some View {
ImageConstrainSizeTest()
}
}
In the screenshot below, I want the red box to shrink to yellow box.
Tried using GeometryReader, but that gives the opposite effect of expanding the yellow box to red box.
Any thoughts?

Here is a demo of possible approach - using view preferences and a bit different layout order (we give area to fit image with aspect and then by resulting image size constrain this area).
Demo prepared & tested with Xcode 11.7 / iOS 13.7
struct ViewSizeKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: CGSize = .zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}
struct ImageConstrainSizeTest: View {
#State private var size = CGSize.zero
var body: some View {
Color.clear
.frame(width: 300, height: 300)
.overlay(
Image("img")
.resizable()
.aspectRatio(contentMode: .fit)
.border(Color.yellow, width: 5)
.background(GeometryReader {
Color.clear.preference(key: ViewSizeKey.self,
value: $0.size) })
)
.onPreferenceChange(ViewSizeKey.self) {
self.size = $0
}
.frame(maxWidth: size.width, maxHeight: size.height)
.border(Color.red, width: 5)
}
}

Related

SwiftUI overlay deprecated alternatives

I'm trying to follow this calculator tutorial but am running into several issues. One of the issues is the use of the .overlay method. I'm assuming it doesn't work because it is deprecated, but I can't figure out how to get the recommeded way or get anything else to work to solve it, so am reaching out for options.
Xcode 12.4
Target: iOS 14.4
Here is the code in that section:
struct CalculatorButtonStyle: ButtonStyle {
var size: CGFloat
var backgroundColor: Color
var foregroundColor: Color
var isWide: Bool = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 32, weight: .medium))
.frame(width: size, height: size)
.frame(maxWidth: isWide ? .infinity : size, alignment: .leading)
.background(backgroundColor)
.foregroundColor(foregroundColor)
/* //Commented out to compile
.overlay( {
if configuration.isPressed {
Color(white: 1.0, opacity: 0.2)
}
)
}
*/
//.clipShape(Capsule()) //this makes circle buttons
} //func
} //struct
I've tried commenting out that section which is the only way to have it compile, but then the button press action of showing a different color does not work.
Overlay is not deprecated. Where did you read that. I think your problem is that you try to use the overlay function in a button style, which is not possible. You can only use it in a view. The error you wrote behind it, also states that.
I'm also not sure what you want to achieve, because doesn't it work correct if you just not use the overlay?
I get this button without the overlay. Is that what you need?
With the button style:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Button("1") {
print("being pressed")
}
.buttonStyle(
CalculatorButtonStyle(
size: 40,
backgroundColor: .cyan,
foregroundColor: .black
)
)
}
.padding()
}
}
I also added the changed button style.
import SwiftUI
struct CalculatorButtonStyle: ButtonStyle {
var size: CGFloat
var backgroundColor: Color
var foregroundColor: Color
var isWide: Bool = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 32, weight: .medium))
.frame(width: size, height: size)
.frame(maxWidth: isWide ? .infinity : size, alignment: .leading)
.background(backgroundColor)
.foregroundColor(configuration.isPressed ? Color(white: 1.0, opacity: 0.2) : foregroundColor)
.clipShape(Capsule()) //this makes circle buttons
}
}
An overlay is not needed. Take advantage of the isPressed value of the configuration to change the color
struct CalculatorButtonStyle: ButtonStyle {
var size: CGFloat
var backgroundColor: Color
var foregroundColor: Color
var isWide: Bool = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 32, weight: .medium))
.frame(width: size, height: size)
.frame(maxWidth: isWide ? .infinity : size, alignment: .leading)
.background(backgroundColor)
.foregroundColor(configuration.isPressed
? Color(white: 1.0, opacity: 0.5)
: foregroundColor)
.clipShape(Capsule())
} //func
} //struct
You have two stray parentheses:
// Remove this
// ↓
.overlay( {
if configuration.isPressed {
Color(white: 1.0, opacity: 0.2)
}
) // ← Remove this
}
The stray left parenthesis (the one immediately after .overlay) prevents Swift from recognizing the trailing closure syntax and makes Swift think you are trying to use the deprecated version of overlay.
The stray right parenthesis is improperly nested relative to the right-brace on the following line.
If you copy and paste the code from the tutorial, or if you remove those two parentheses, the code compiles:
struct CalculatorButtonStyle: ButtonStyle {
var size: CGFloat
var backgroundColor: Color
var foregroundColor: Color
var isWide: Bool = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 32, weight: .medium))
.frame(width: size, height: size)
.frame(maxWidth: isWide ? .infinity : size, alignment: .leading)
.background(backgroundColor)
.foregroundColor(foregroundColor)
.overlay {
if configuration.isPressed {
Color(white: 1.0, opacity: 0.2)
}
}
.clipShape(Capsule())
}
}

SwiftUI DragGesture is freeze

I tried to do a resizable view with a gesture.
The problem is that when I move my gesture object left or right the app will be freeze with 100% processor usage. It is the loop between two Text views.
What did I do wrong, and how do I make this correct?
struct TestView2 : View {
#State private var width : CGFloat = 400.0
var body : some View {
VStack {
ZStack(alignment: .bottomTrailing) {
Text("\(width)")
.frame(width: width)
Text(":")
.frame(width: 10, height: 30)
.background(.bar)
.gesture(
DragGesture()
.onChanged { value in
width = max(100, width + value.translation.width)
print(width)
}
)
}
.frame(width: width, height: 30, alignment: .topLeading)
.border(.gray, width: 1)
.background(.green)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
View:
Debugger:
For me, the solution was to explicitly set the gesture's coordinateSpace to .global:
DragGesture(coordinateSpace: .global)
.onChanged { ... }

SwiftUI Dynamic Image Sizing

Problem:I have a View that I needed to place multiple (2) views that contained: 1 Image + 1 Text. I decided to break that up into a ClickableImageAndText structure that I called on twice. This works perfectly if the image is a set size (64x64) but I would like this to work on all size classes. Now, I know that I can do the following:
if horizontalSizeClass == .compact {
Text("Compact")
} else {
Text("Regular")
}
but I am asking for both Different Size Classes and Same Size Classes such as the iPhone X and iPhone 13 which are the same.
Question:How do I alter the image for dynamic phone sizes (iPhone X, 13, 13 pro, etc) so it looks appropriate for all measurements?
Code:
import SwiftUI
struct ClickableImageAndText: View {
let image: String
let text: String
let tapAction: (() -> Void)
var body: some View {
VStack {
Image(image)
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
Text(text)
.foregroundColor(.white)
}
.contentShape(Rectangle())
.onTapGesture {
tapAction()
}
}
}
struct InitialView: View {
var topView: some View {
Image("Empty_App_Icon")
.resizable()
.scaledToFit()
}
var bottomView: some View {
VStack {
ClickableImageAndText(
image: "Card_Icon",
text: "View Your Memories") {
print("Tapped on View Memories")
}
.padding(.bottom)
ClickableImageAndText(
image: "Camera",
text: "Add Memories") {
print("Tapped on Add Memories")
}
.padding(.top)
}
}
var body: some View {
ZStack {
GradientView()
VStack {
Spacer()
topView
Spacer()
bottomView
Spacer()
}
}
}
}
struct InitialView_Previews: PreviewProvider {
static var previews: some View {
InitialView()
}
}
Image Note:My background includes a GradientView that I have since removed (thanks #lorem ipsum). If you so desire, here is the GradientView code but it is unnecessary for the problem above.
GradientView.swift
import SwiftUI
struct GradientView: View {
let firstColor = Color(uiColor: UIColor(red: 127/255, green: 71/255, blue: 221/255, alpha: 1))
let secondColor = Color(uiColor: UIColor(red: 251/255, green: 174/255, blue: 23/255, alpha: 1))
let startPoint = UnitPoint(x: 0, y: 0)
let endPoint = UnitPoint(x: 0.5, y: 1)
var body: some View {
LinearGradient(gradient:
Gradient(
colors: [firstColor, secondColor]),
startPoint: startPoint,
endPoint: endPoint)
.ignoresSafeArea()
}
}
struct GradientView_Previews: PreviewProvider {
static var previews: some View {
GradientView()
}
}
Effort 1:Added a GeometryReader to my ClickableImageAndText structure and the view is automatically changed incorrectly.
struct ClickableImageAndText: View {
let image: String
let text: String
let tapAction: (() -> Void)
var body: some View {
GeometryReader { reader in
VStack {
Image(image)
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
Text(text)
.foregroundColor(.white)
}
.contentShape(Rectangle())
.onTapGesture {
tapAction()
}
}
}
}
Effort 2:Added a GeometryReader as directed by #loremipsum's [deleted] answer and the content is still being pushed; specifically, the topView is being push to the top and the bottomView is taking the entire space with the addition of the GeometryReader.
struct ClickableImageAndText: View {
let image: String
let text: String
let tapAction: (() -> Void)
var body: some View {
GeometryReader{ geo in
VStack {
Image(image)
.resizable()
.scaledToFit()
//You can do this and set strict size constraints
//.frame(minWidth: 64, maxWidth: 128, minHeight: 64, maxHeight: 128, alignment: .center)
//Or this to set it to be percentage of the size of the screen
.frame(width: geo.size.width * 0.2, alignment: .center)
Text(text)
}.foregroundColor(.white)
//Everything moves to the left because the `View` expecting a size vs stretching.
//If yo want the entire width just set the View with on the outer most View
.frame(width: geo.size.width, alignment: .center)
}
.contentShape(Rectangle())
.onTapGesture {
tapAction()
}
}
}
The possible solution is to use screen bounds (which will be different for different phones) as reference value to calculate per-cent-based dynamic size for image. And to track device orientation changes we wrap our calculations into GeometryReader.
Note: I don't have your images, so added white borders for demo purpose
struct ClickableImageAndText: View {
let image: String
let text: String
let tapAction: (() -> Void)
#State private var size = CGFloat(32) // some minimal initial value (not 0)
var body: some View {
VStack {
Image(image)
.resizable()
.scaledToFit()
// .border(Color.white) // << for demo !!
.background(GeometryReader { _ in
// GeometryReader is needed to track orientation changes
let sizeX = UIScreen.main.bounds.width
let sizeY = UIScreen.main.bounds.height
// Screen bounds is needed for reference dimentions, and use
// it to calculate needed size as per-cent to be dynamic
let width = min(sizeX, sizeY)
Color.clear // % (whichever you want)
.preference(key: ViewWidthKey.self, value: width * 0.2)
})
.onPreferenceChange(ViewWidthKey.self) {
self.size = max($0, size)
}
.frame(width: size, height: size)
Text(text)
.foregroundColor(.white)
}
.contentShape(Rectangle())
.onTapGesture {
tapAction()
}
}
}

SwiftUI shape fill body

I'm trying to construct a view in SwiftUI, where the user can keep zooming in and out, and show elements across the view. But the rectangle keeps the size of the window, and scales down when zooming out instead of filling the body. The body (black) correctly fills the window.
How do you make the white rectangle fill the body when zooming out?
(Must be run in an app instead of preview)
import SwiftUI
func rgb (_ count: Int) -> [Color]{
let colors = [Color.red, Color.green, Color.blue]
var arr: [Color] = []
for i in 0..<count {
arr.append(colors[i%3])
}
return arr
}
struct ContentView: View {
#State var scale: CGFloat = 1.0
var body: some View {
let colors = rgb(20)
ZStack {
Rectangle()
.fill(Color.white)
.frame(minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .center)
ForEach(colors.indices.reversed(), id: \.self) { i in
Circle()
.size(width: 100, height: 100)
.fill(colors[i])
.offset(x: 100.0*CGFloat(i), y: 100.0*CGFloat(i))
}
}
.drawingGroup()
.scaleEffect(scale)
.gesture(MagnificationGesture()
.onChanged {self.scale = $0})
.background(Color.black)
.frame(minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .center)
}
}
I put this an an answer to show a screenshot. The second bit of code behaves very inconsistently. I never see 20 circles. It will zoom, but seems to then be caught in some other view. It is very strange behavior and tough to explain. While the screenshot is here, I could run it 20 times and get 20 different screenshots if I zoom and/or resize the window. I am not on Apple silicon, so your first post may be a bug in implementation on Apple silicon. Wouldn't be the first.
Functioning example for this use case, with rectangle removed from ZStack:
import SwiftUI
func rgb (_ count: Int) -> [Color]{
let colors = [Color.red, Color.green, Color.blue]
var arr: [Color] = []
for i in 0..<count {
arr.append(colors[i%3])
}
return arr
}
struct ContentView: View {
#State var scale: CGFloat = 1.0
#State var colorIndex = 0
var bgColor: Color { rgb(3)[colorIndex%3] }
var body: some View {
let colors = rgb(20)
ZStack {
ForEach(colors.indices.reversed(), id: \.self) { i in
Circle()
.size(width: 100, height: 100)
.fill(colors[i])
.offset(x: 100.0*CGFloat(i), y: 100.0*CGFloat(i))
}
}
.drawingGroup()
.scaleEffect(scale)
.background(bgColor)
.gesture(MagnificationGesture()
.onChanged {scale = $0})
.gesture(TapGesture().onEnded({colorIndex+=1}))
}
}
However it does not fix the problem of the shape not scaling to the body size.

Content hugging priority behaviour in SwiftUI

I have a List made of cells, each containing an image, and a column of text, which I wish laid out in a specific way. Image on the left, taking up a quarter of the width. The rest of the space given to the text, which is left-aligned.
Here's the code I got:
struct TestCell: View {
let model: ModelStruct
var body: some View {
HStack {
Image("flag")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: UIScreen.main.bounds.size.width * 0.25)
VStack(alignment: .leading, spacing: 5) {
Text("Country: Moldova")
Text("Capital: Chișinău")
Text("Currency: Leu")
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
}
}
}
struct TestCell_Previews: PreviewProvider {
static var previews: some View {
TestCell()
.previewLayout(.sizeThatFits)
.previewDevice("iPhone 11")
}
}
And here are 2 examples:
As you can see, the height of the whole cell varies based on the aspect ratio of the image.
$1M question - How can we make the cell height hug the text (like in the second image) and not vary, but rather shrink the image in a scaleAspectFit manner inside the allocated rectangle
Note!
The text's height can vary, so no hardcoding.
Couldn't make it work with PreferenceKeys, as the cells will be part of a List, and there's some peculiar behaviour I'm trying to grasp around cell reusage, and onPreferenceChange not being called when 2 consecutive cells have the same height. To exhibit all this combined behaviour, make sure your model varies between cells when you test it.
Here is a possible solution, however it uses GeometryReader inside the background property of the VStack, to detect their height. That height is being applied to the Image then. I used SizePreferenceKey from this solution.
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: Value = .zero
static func reduce(value _: inout Value, nextValue: () -> Value) {
_ = nextValue()
}
}
struct ContentView6: View {
#State var childSize: CGSize = .zero
var body: some View {
HStack {
Image("image1")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: UIScreen.main.bounds.size.width * 0.25, height: self.childSize.height)
VStack(alignment: .leading, spacing: 5) {
Text("Country: Moldova")
Text("Capital: Chișinău")
Text("Currency: Leu")
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(
GeometryReader { proxy in
Color.clear.preference(key: SizePreferenceKey.self, value: proxy.size)
}
)
}
.onPreferenceChange(SizePreferenceKey.self) { preferences in
self.childSize = preferences
}
.border(Color.yellow)
}
}
Will look like this.. you can apply different aspect ratios for the Image of course.
This is what worked for me to constrain a color view to the height of text content in a cell:
A height reader view:
struct HeightReader: View {
#Binding var height: CGFloat
var body: some View {
GeometryReader { proxy -> Color in
update(with: proxy.size.height)
return Color.clear
}
}
private func update(with value: CGFloat) {
guard value != height else { return }
DispatchQueue.main.async {
height = value
}
}
}
You can then use the reader in a compound view as a background on the view you wish to constrain to, using a state object to update the frame of the view you wish to constrain:
struct CompoundView: View {
#State var height: CGFloat = 0
var body: some View {
HStack(alignment: .top) {
Color.red
.frame(width: 2, height: height)
VStack(alignment: .leading) {
Text("Some text")
Text("Some more text")
}
.background(HeightReader(height: $height))
}
}
}
struct CompoundView_Previews: PreviewProvider {
static var previews: some View {
CompoundView()
}
}
I have found that using DispatchQueue to update the binding is important.