Programming

SwiftUI Layout – Deciphering the Size Code | fatbobman

[ad_1]

picture by Black Deon on Unsplash

In “SwiftUI Layout — The Sizing Mystery” we explained many of the sizing concepts involved in the SwiftUI layout process. In this article, we will further deepen our understanding of the SwiftUI layout mechanism by mimicking view modifiers. frame And fixedSizeand demonstrate some issues to be aware of when layouting through several examples.

In SwiftUI, we can use different layout containers to generate almost identical rendering results. For example, ZStack, overlay, background, VStack and HStack can all achieve similar layout effects.

Here is an example using ZStack, overlay and background:

struct HeartView: View {
var body: some View {
Circle()
.fill(.yellow)
.frame(width: 30, height: 30)
.overlay(Image(systemName: "heart").foregroundColor(.red))
}
}

struct ButtonView: View {
var body: some View {
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue.gradient)
.frame(width: 150, height: 50)
}
}

// ZStack
struct IconDemo1: View {
var body: some View {
ZStack(alignment: .topTrailing) {
ButtonView()
HeartView()
.alignmentGuide(.top, computeValue: { $0.height / 2 })
.alignmentGuide(.trailing, computeValue: { $0.width / 2 })
}
}
}

// overlay
struct IconDemo2: View {
var body: some View {
ButtonView()
.overlay(alignment: .topTrailing) {
HeartView()
.alignmentGuide(.top, computeValue: { $0.height / 2 })
.alignmentGuide(.trailing, computeValue: { $0.width / 2 })
}
}
}

// background
struct IconDemo3: View {
var body: some View {
HeartView()
.background(alignment:.center){
ButtonView()
.alignmentGuide(HorizontalAlignment.center, computeValue: {$0(.trailing)})
.alignmentGuide(VerticalAlignment.center, computeValue: {$0(.top)})
}
}
}

Although IconDemo1, IconDemo2And IconDemo3 look the same in the isolated preview, their placement in other layout containers reveals distinct differences in their layout result inside the container. The composition and size of the required size are different (see the required size of each marked by the red box in the figure below).

This is because different presentation containers have different strategies to plan their own required size, which leads to the above phenomenon.

Containers like ZStack, VStack and HStack, their required size is the total size obtained after placing all their subviews according to the specified layout rules. Although the required size of the overlay and background is entirely dependent on their primary view (in this example the required size of the overlay is determined by ButtonViewand the required size of the background is determined by HeartView).

Suppose the current design requirement is to have ButtonView And HeartView as a whole, then ZStack is a good choice.

Each container has its applicable scenarios. For example, in the following requirement, to create a sub-view similar to the “Like” function in a video application (consider only the position and size of the gesture icon when highlighting page), the overlay container which only depends on the required size of the main view. the view is very appropriate:

struct FavoriteDemo: View {
var body: some View {
ZStack(alignment: .bottomTrailing) {
Rectangle()
.fill(Color.cyan.gradient.opacity(0.5))
Favorite()
.alignmentGuide(.bottom, computeValue: { $0(.bottom) + 200 })
.alignmentGuide(.trailing, computeValue: { $0(.trailing) + 100 })
}
.ignoresSafeArea()
}
}

struct Favorite: View {
@State var hearts = ((String, CGFloat, CGFloat))()
var body: some View {
Image(systemName: "hand.thumbsup")
.symbolVariant(.fill)
.foregroundColor(.blue)
.font(.title)
.overlay(alignment: .bottom) {
ZStack {
Color.clear
ForEach(hearts, id: \.0) { heart in
Text("+1")
.font(.title)
.foregroundColor(.white)
.bold()
.transition(.asymmetric(insertion: .move(edge: .bottom).combined(with: .opacity), removal: .move(edge: .top).combined(with: .opacity)))
.offset(x: heart.1, y: heart.2)
.task {
try? await Task.sleep(nanoseconds: 500000000)
if let index = hearts.firstIndex(where: { $0.0 == heart.0 }) {
let _ = withAnimation(.easeIn) {
hearts.remove(at: index)
}
}
}
}
}
.frame(width: 50, height: 100)
.allowsHitTesting(false)
}
.onTapGesture {
withAnimation(.easeOut) {
hearts.append((UUID().uuidString, .random(in: -10...10), .random(in: -10...10)))
}
}
}
}

Views of the same appearance can have different implications. When using layout containers to create combination views, the impact on the layout of the compound view’s parent container should be considered and an appropriate container should be chosen for different requirements.

Similar to UIKit and AppKit, SwiftUI’s layout operations are performed at the view (essence) level, while all operations targeting the associated support layer are still performed through Core Animation. Therefore, adjustments made directly to the CALayer (appearance) are undetectable by SwiftUI’s layout system.

Such operations that adjust content after layout but before rendering are prevalent in SwiftUI, for example: offset, scaleEffect, rotationEffect, shadow, background, cornerRadiusetc., are performed at this stage.

Here is an example :

struct OffsetDemo1:View{
var body: some View{
HStack{
Rectangle()
.fill(.orange.gradient)
.frame(maxWidth:.infinity)
Rectangle()
.fill(.green.gradient)
.frame(maxWidth:.infinity)
Rectangle()
.fill(.cyan.gradient)
.frame(maxWidth:.infinity)
}
.border(.red)
}
}

We adjust the position of the middle rectangle with an offset, which does not affect the size of HStack. In this case, appearance and essence are decoupled:

Rectangle()
.fill(.green.gradient)
.frame(width: 100, height: 50)
.border(.blue)
.offset(x: 30, y: 30)
.border(.green)

In SwiftUI, the offset modifier corresponds to the CGAffineTransform operation in Core Animation. .offset(x: 30, y: 30) is equivalent to .transformEffect(.init(translationX: 30, y: 30)). Such changes made directly at the CALayer level do not affect the layout.

The above effect might be the desired effect, but if you want the moved view to affect the layout of its parent (container) view, you might need another approach: use layout containers instead. location of Core Animation operations:

// Using padding
Rectangle()
.fill(.green.gradient)
.frame(width: 100, height: 50)
.border(.blue)
.padding(EdgeInsets(top: 30, leading: 30, bottom: 0, trailing: 0))
.border(.green)

Or it may look like this:

// Using frame
Rectangle()
.fill(.green.gradient)
.frame(width: 100, height: 50)
.border(.blue)
.frame(width: 130, height: 80, alignment: .bottomTrailing)
.border(.green)

// Using position
Rectangle()
.fill(.green.gradient)
.frame(width: 100, height: 50)
.border(.blue)
.position(x: 80, y: 55)
.frame(width: 130, height: 80)
.border(.green)

Compared to the offset view modifier, since there is no ready replacement, it is a bit cumbersome to do the results of rotationEffectin turn affect the layout:

struct RotationDemo: View {
var body: some View {
HStack(alignment: .center) {
Text("HI")
.border(.red)
Text("Hello world")
.fixedSize()
.border(.yellow)
.rotationEffect(.degrees(-40))
.border(.red)
}
.border(.blue)
}
}
extension View {
func rotationEffectWithFrame(_ angle: Angle) -> some View {
modifier(RotationEffectWithFrameModifier(angle: angle))
}
}

struct RotationEffectWithFrameModifier: ViewModifier {
let angle: Angle
@State private var size: CGSize = .zero
var bounds: CGRect {
CGRect(origin: .zero, size: size)
.offsetBy(dx: -size.width / 2, dy: -size.height / 2)
.applying(.init(rotationAngle: CGFloat(angle.radians)))
}
func body(content: Content) -> some View {
content
.rotationEffect(angle)
.background(
GeometryReader { proxy in
Color.clear
.task(id: proxy.frame(in: .local)) {
size = proxy.size
}
}
)
.frame(width: bounds.width, height: bounds.height)
}
}

struct RotationDemo: View {
var body: some View {
HStack(alignment: .center) {
Text("HI")
.border(.red)
Text("Hello world")
.fixedSize()
.border(.yellow)
.rotationEffectWithFrame(.degrees(-40))
.border(.red)
}
.border(.blue)
}
}

scaleEffect can also be implemented in the same way to affect the original layout.

In SwiftUI, developers must specify whether an operation targets essence (based on layout mechanism) or appearance (at CALayer level). They can also see if he wants to affect the essence by changing the appearance. This way, the final rendered effect can be consistent with the expected layout.

Please read”SwiftUI-style layoutto learn how to use different layout logic in SwiftUI to meet the same visual design requirements.

In this chapter, we will deepen the understanding of different concepts of size in the layout process by mimicking frame And fixedSize using the Layout protocol.

The layout logic of frame And fixedSize has been described in the previous section; this section only explains the key code. Imitation code can be obtained here.

There are two versions of frame in SwiftUI. This section mimics frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center).

Essentially, the frame view modifier is a wrapper around the _FrameLayout layout container. In this example, we name the custom layout container MyFrameLayout and the view modifier myFrame.

In SwiftUI, layout containers generally need to be wrapped before using them. For example, _VStackLayout is wrapped like VStack, _FrameLayout is wrapped like the frame view modifier.

The effect of this packing behavior is (taking MyFrameLayout For example):

Improve the problem of multiple parentheses caused by Layout the protocol callAsFunction

In “Alignment in SwiftUI: Everything You Need to Know,» I introduced that “alignment” happens between subviews inside a container. Therefore, for _FrameLayout, which only takes one subview from the developer but still requires alignment. You have to add a Color.clear sight in modifier to address lack of alignment objects.

private struct MyFrameLayout: Layout, ViewModifier {
let width: CGFloat?
let height: CGFloat?
let alignment: Alignment

func body(content: Content) -> some View {
MyFrameLayout(width: width, height: height, alignment: alignment)() { // Due to the multiple parentheses caused by callAsFunction
Color.clear // Add views for alignment assistance.
content
}
}
}

public extension View {
func myFrame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View {
self
.modifier(MyFrameLayout(width: width, height: height, alignment: alignment))
}
@available(*, deprecated, message: "Please pass one or more parameters.")
func myFrame() -> some View {
modifier(MyFrameLayout(width: nil, height: nil, alignment: .center))
}
}

This version of the framework has the following features:

  • When both dimensions have specific values ​​defined, use those two values ​​as the required size of the _FrameLayout container and the layout size of the subview.
  • When only one dimension has a specific A value defined, use that A value as the required size of the _FrameLayout container in this dimension. For the other dimension, use the required size of the subview as the required size (use A and the proposed size obtained by _FrameLayout as the proposed size of the sub-view).
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard subviews.count == 2, let content = subviews.last else { fatalError("Can't use MyFrameLayout directly") }
var result: CGSize = .zero

if let width, let height { // Both dimensions are set.
result = .init(width: width, height: height)
}
if let width, height == nil { // Only width is set
let contentHeight = content.sizeThatFits(.init(width: width, height: proposal.height)).height // Required size of the subview on this dimension
result = .init(width: width, height: contentHeight)
}
if let height, width == nil {
let contentWidth = content.sizeThatFits(.init(width: proposal.width, height: height)).width
result = .init(width: contentWidth, height: height)
}
if height == nil, width == nil {
result = content.sizeThatFits(proposal)
}
return result
}

In placeSubviewswe will use the auxiliary view added in the modifier to align and place the subview.

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard subviews.count == 2, let background = subviews.first, let content = subviews.last else {
fatalError("Can't use MyFrameLayout directly")
}
background.place(at: .zero, anchor: .topLeading, proposal: .init(width: bounds.width, height: bounds.height))
// Get the position of the Color.clear's alignment guide
let backgroundDimensions = background.dimensions(in: .init(width: bounds.width, height: bounds.height))
let offsetX = backgroundDimensions(alignment.horizontal)
let offsetY = backgroundDimensions(alignment.vertical)
// Get the position of the subview's alignment guide
let contentDimensions = content.dimensions(in: .init(width: bounds.width, height: bounds.height))
// Calculate the topLeading offset of content
let leading = offsetX - contentDimensions(alignment.horizontal) + bounds.minX
let top = offsetY - contentDimensions(alignment.vertical) + bounds.minY
content.place(at: .init(x: leading, y: top), anchor: .topLeading, proposal: .init(width: bounds.width, height: bounds.height))
}

Now we can use myFrame to replace the frame in the views and achieve the same effect.

fixedSize provides a proposed size in unspecified mode (nil) for a specific dimension of the subview. It does this to return the ideal size as the required size in that dimension and use that size as its own required size returned to the parent view.

private struct MyFixedSizeLayout: Layout, ViewModifier {
let horizontal: Bool
let vertical: Bool

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard subviews.count == 1, let content = subviews.first else {
fatalError("Can't use MyFixedSizeLayout directly")
}
// Prepare the proposed size for submission to the subview
let width = horizontal ? nil : proposal.width // If horizontal is true then submit the proposal dimensions for the unspecified mode, otherwise provide the suggested dimensions for the parent view in this dimension
let height = vertical ? nil : proposal.height // If vertical is true then submit the proposal dimensions for the unspecified mode, otherwise provide the suggested dimensions for the parent view in this dimension
let size = content.sizeThatFits(.init(width: width, height: height)) // Submits the proposal dimensions determined above to the subview and gets the subview's required dimensions
return size // Take the required size of the child view as the required size of the MyFixedSizeLayout container
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard subviews.count == 1, let content = subviews.first else {
fatalError("Can't use MyFixedSizeLayout directly")
}
content.place(at: .init(x: bounds.minX, y: bounds.minY), anchor: .topLeading, proposal: .init(width: bounds.width, height: bounds.height))
}

func body(content: Content) -> some View {
MyFixedSizeLayout(horizontal: horizontal, vertical: vertical)() {
content
}
}
}

public extension View {
func myFixedSize(horizontal: Bool, vertical: Bool) -> some View {
modifier(MyFixedSizeLayout(horizontal: horizontal, vertical: vertical))
}
func myFixedSize() -> some View {
myFixedSize(horizontal: true, vertical: true)
}
}

Given the huge differences between the two versions of frame, both functionally and in terms of implementation, they correspond to different layout containers in SwiftUI. frame(minWidth:, idealWidth: , maxWidth: , minHeight: , idealHeight:, maxHeight: , alignment:) is a wrapping around the _FlexFrameLayout layout container.

_FlexFrameLayout is essentially a combination of two features:

  • When the ideal value is set and the parent view provides a suggested size in unspecified mode in this dimension, return the ideal value as the required size and use it as the subview’s layout size.
  • When min or (and) max has a value, returns the required size in that dimension according to the following rules (SwiftUI-Lab diagram):
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard subviews.count == 2, let content = subviews.last else { fatalError("Can't use MyFlexFrameLayout directly") }

var resultWidth: CGFloat = 0
var resultHeight: CGFloat = 0
let contentWidth = content.sizeThatFits(proposal).width // Get the required size of the child view in terms of width using the proposal size of the parent view as the proposal size
// idealWidth has a value and the parent view has an unspecified mode for the proposal size in terms of width, the required width is idealWidth
if let idealWidth, proposal.width == nil {
resultWidth = idealWidth
} else if minWidth == nil, maxWidth == nil { // min and max are both unspecified, returning the required dimensions of the child view in terms of width.
resultWidth = contentWidth
} else if let minWidth, let maxWidth { // If both min and max have values
resultWidth = clamp(min: minWidth, max: maxWidth, source: proposal.width ?? contentWidth)
} else if let minWidth { // min If there is a value, make sure that the requirement size is not smaller than the minimum value.
resultWidth = clamp(min: minWidth, max: maxWidth, source: contentWidth)
} else if let maxWidth { // When max has a value, make sure that the required size is not larger than the maximum value.
resultWidth = clamp(min: minWidth, max: maxWidth, source: proposal.width ?? contentWidth)
}
// Use the required width determined above as the proposal width to get the required height of the child view
let contentHeight = content.sizeThatFits(.init(width: proposal.width == nil ? nil : resultWidth, height: proposal.height)).height
if let idealHeight, proposal.height == nil {
resultHeight = idealHeight
} else if minHeight == nil, maxHeight == nil {
resultHeight = contentHeight
} else if let minHeight, let maxHeight {
resultHeight = clamp(min: minHeight, max: maxHeight, source: proposal.height ?? contentHeight)
} else if let minHeight {
resultHeight = clamp(min: minHeight, max: maxHeight, source: contentHeight)
} else if let maxHeight {
resultHeight = clamp(min: minHeight, max: maxHeight, source: proposal.height ?? contentHeight)
}
let size = CGSize(width: resultWidth, height: resultHeight)
return size
}

// Limit values to between minimum and maximum
func clamp(min: CGFloat?, max: CGFloat?, source: CGFloat) -> CGFloat {
var result: CGFloat = source
if let min {
result = Swift.max(source, min)
}
if let max {
result = Swift.min(source, max)
}
return result
}

In the View extension, you can check if min, idealAnd max the values ​​are in ascending order:

public extension View {
func myFrame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center) -> some View {
// min < ideal < max
func areInNondecreasingOrder(
_ min: CGFloat?, _ ideal: CGFloat?, _ max: CGFloat?
) -> Bool {
let min = min ?? -.infinity
let ideal = ideal ?? min
let max = max ?? ideal
return min <= ideal && ideal <= max
}

// The official SwiftUI implementation will still execute in case of a numerical error, but will display an error message in the console.
if !areInNondecreasingOrder(minWidth, idealWidth, maxWidth)
|| !areInNondecreasingOrder(minHeight, idealHeight, maxHeight) {
fatalError("Contradictory frame constraints specified.")
}
return modifier(MyFlexFrameLayout(minWidth: minWidth, idealWidth: idealWidth, maxWidth: maxWidth, minHeight: minHeight, idealHeight: idealHeight, maxHeight: maxHeight, alignment: alignment))
}
}

THE Layout The protocol provides an excellent window to gain an in-depth understanding of the SwiftUI layout mechanism. Whether you need to create custom layout containers using the tool Layout protocol in your future work or not, mastering it will bring you great benefits.

I hope this article will help you.

[ad_2]

Source link

Related Articles

Back to top button