diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 0cf8f6a88..9582a9abe 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -120,6 +120,7 @@ 275751F62DEE1456003E467C /* OpenSwiftUIUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = OpenSwiftUIUITests; sourceTree = ""; }; 279FEC572DF450D200320390 /* ReferenceImages */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ReferenceImages; sourceTree = ""; }; 27E6C4F62D2842D80010502F /* Configurations */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); name = Configurations; path = ../Configurations; sourceTree = ""; }; + 27FFF0422E08850C0060A4DA /* SharedExample */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SharedExample; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -179,6 +180,7 @@ 27E6C4D02D2842530010502F /* OpenBox */, 271D81642BB1E8E300A6D543 /* OpenGraph */, 27CD0B612AFC8E0E003665EB /* OpenSwiftUI */, + 27FFF0422E08850C0060A4DA /* SharedExample */, 275751AF2DEE136A003E467C /* Example */, 275751C12DEE136C003E467C /* HostingExample */, 275751E42DEE1441003E467C /* TestingHost */, @@ -277,6 +279,7 @@ ); fileSystemSynchronizedGroups = ( 275751AF2DEE136A003E467C /* Example */, + 27FFF0422E08850C0060A4DA /* SharedExample */, ); name = Example; packageProductDependencies = ( @@ -300,6 +303,7 @@ ); fileSystemSynchronizedGroups = ( 275751C12DEE136C003E467C /* HostingExample */, + 27FFF0422E08850C0060A4DA /* SharedExample */, ); name = HostingExample; packageProductDependencies = ( diff --git a/Example/HostingExample/ViewController.swift b/Example/HostingExample/ViewController.swift index f6979c5b0..d82fcdf54 100644 --- a/Example/HostingExample/ViewController.swift +++ b/Example/HostingExample/ViewController.swift @@ -66,18 +66,9 @@ class ViewController: NSViewController { struct ContentView: View { var body: some View { - ZStack(alignment: .leading) { - Color.red - .opacity(0.5) - .frame(width: 200, height: 200) - } - .overlay(alignment: .topLeading) { - Color.green.opacity(0.5) - .frame(width: 100, height: 100) - } - .background(alignment: .bottomTrailing) { - Color.blue.opacity(0.5) - .frame(width: 100, height: 100) - } + MyViewThatFitsByLayout { + Color.red.frame(width: 50, height: 50) + Color.green.frame(width: 50, height: 50) + }.frame(width: 200, height: 200) } } diff --git a/Example/SharedExample/Layout/MyViewThatFitsByLayout.swift b/Example/SharedExample/Layout/MyViewThatFitsByLayout.swift new file mode 100644 index 000000000..e56d440cc --- /dev/null +++ b/Example/SharedExample/Layout/MyViewThatFitsByLayout.swift @@ -0,0 +1,84 @@ +// +// MyViewThatFitsByLayout.swift +// SharedExample +// +// Modified from https://github.com/fatbobman/BlogCodes/blob/main/ViewThatFits/ViewThatFits/MyViewThatFitsByLayout.swift +// Copyright © 2022 Yang Xu. All rights reserved. + +import Foundation +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +struct _MyViewThatFitsLayout: Layout { + let axis: Axis.Set + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Int?) -> CGSize { + // No subviews, return zero + guard !subviews.isEmpty else { return .zero } + // One subview, returns the required size of the subview + guard subviews.count > 1 else { + cache = subviews.endIndex - 1 + return subviews[subviews.endIndex - 1].sizeThatFits(proposal) + } + // From the first to the penultimate subview, obtain its ideal size in the limited axis one by one for judgment. + for i in 0.. Int? { + nil + } +} + +public struct MyViewThatFitsByLayout: View where Content: View { + let axis: Axis.Set + let content: Content + + public init(axis: Axis.Set = [.horizontal, .vertical], @ViewBuilder content: @escaping () -> Content) { + self.axis = axis + self.content = content() + } + + public var body: some View { + _MyViewThatFitsLayout(axis: axis) { + content + } + } +} diff --git a/Example/SharedExample/Layout/MyZStackLayout.swift b/Example/SharedExample/Layout/MyZStackLayout.swift new file mode 100644 index 000000000..0f90470b6 --- /dev/null +++ b/Example/SharedExample/Layout/MyZStackLayout.swift @@ -0,0 +1,156 @@ +// +// MyZStackLayout.swift +// SharedExample +// +// Modified from https://github.com/fatbobman/BlogCodes/blob/main/MyZStack/MyZStack/_MyZStackLayout.swift +// Copyright © 2022 Yang Xu. All rights reserved. + +import Foundation +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +private struct _MyZStackLayout: Layout { + let alignment: Alignment + + func makeCache(subviews: Subviews) -> CacheInfo { + .init() + } + + // 容器的父视图(父容器)将通过调用容器的 sizeThatFits 获取容器的需求尺寸,本方法通常会被多次调用,并提供不同的建议尺寸 + func sizeThatFits( + proposal: ProposedViewSize, // 容器的父视图(父容器)提供的建议尺寸 + subviews: Subviews, // 当前容器内的所有子视图的代理 + cache: inout CacheInfo // 缓存数据,本例中用于保存子视图的返回的需求尺寸,减少调用次数 + ) -> CGSize { + cache = .init() // 清除缓存 + for subview in subviews { + // 为子视图提供建议尺寸,获取子视图的需求尺寸 (ViewDimensions) + let viewDimension = subview.dimensions(in: proposal) + // 根据 MyZStack 的 alignment 的设置获取子视图的 alignmentGuide + let alignmentGuide: CGPoint = .init( + x: viewDimension[alignment.horizontal], + y: viewDimension[alignment.vertical] + ) + // 以子视图的 alignmentGuide 为 (0,0) , 在虚拟的画布中,为子视图创建 CGRect + let bounds: CGRect = .init( + origin: .init(x: -alignmentGuide.x, y: -alignmentGuide.y), + size: .init(width: viewDimension.width, height: viewDimension.height) + ) + // 保存子视图在虚拟画布中的数据 + cache.subviewInfo.append(.init(viewDimension: viewDimension, bounds: bounds)) + } + + // 根据所有子视图在虚拟画布中的数据,生成 MyZtack 的 CGRect + cache.cropBounds = cache.subviewInfo.map(\.bounds).cropBounds() + // 返回当前容器的理想尺寸, 当前容器的父视图将使用该尺寸在它的内部进行摆放 + return cache.cropBounds.size + } + + // 容器的父视图(父容器)将在需要的时机调用本方法,为本容器的子视图设置渲染位置 + func placeSubviews( + in bounds: CGRect, // 根据当前容器在 sizeThatFits 提供的尺寸,在真实渲染处创建的 CGRect + proposal: ProposedViewSize, // 容器的父视图(父容器)提供的建议尺寸 + subviews: Subviews, // 当前容器内的所有子视图的代理 + cache: inout CacheInfo // 缓存数据,本例中用于保存子视图的返回的需求尺寸,减少调用次数 + ) { + // 虚拟画布左上角的偏移值 ( 到 0,0 ) + let offsetX = cache.cropBounds.minX * -1 + let offsetY = cache.cropBounds.minY * -1 + + for index in subviews.indices { + let info = cache.subviewInfo[index] + // 将虚拟画布中的位置信息转换成渲染 bounds 的位置信息 + let x = transformPoint(original: info.bounds.minX, offset: offsetX, targetBoundsMinX: bounds.minX) + let y = transformPoint(original: info.bounds.minY, offset: offsetY, targetBoundsMinX: bounds.minY) + // 将转换后的位置信息设置到子视图上,并为子视图设置渲染尺寸 + subviews[index].place(at: .init(x: x, y: y), anchor: .topLeading, proposal: proposal) + } + } + + // SwiftUI 通过此方法来获取特定的对齐参考的显式值 + func explicitAlignment( + of guide: VerticalAlignment, // 查询的对齐指导 + in bounds: CGRect, // 自定义容器的 bounds ,该 bounds 的尺寸由 sizeThatFits 方法计算得出,与 placeSubviews 的 bounds 参数一致 + proposal: ProposedViewSize, // 父视图的推荐尺寸 + subviews: Subviews, // 容器内的子视图代理 + cache: inout CacheInfo // 缓存数据,本例中,我们在缓存数据中保存了每个子视图的 viewDimension、虚拟 bounds 能信息 + ) -> CGFloat? { + let offsetY = cache.cropBounds.minY * -1 + let infinity: CGFloat = .infinity + + // 检查子视图中是否有 显式 firstTextBaseline 不为 nil 的视图。如果有,则返回位置最高的 firstTextBaseline 值。 + if guide == .firstTextBaseline,!cache.subviewInfo.isEmpty { + let firstTextBaseline = cache.subviewInfo.reduce(infinity) { current, info in + let baseline = info.viewDimension[explicit: .firstTextBaseline] ?? infinity + // 将子视图的显式 firstTextBaseline 转换成 bounds 中的偏移值 + let transformBaseline = transformPoint(original: baseline + info.bounds.minY, offset: offsetY, targetBoundsMinX: 0) + // 返回位置最高的值( 值最小 ) + return min(current, transformBaseline) + } + return firstTextBaseline != infinity ? firstTextBaseline : nil + } + + if guide == .lastTextBaseline,!cache.subviewInfo.isEmpty { + let lastTextBaseline = cache.subviewInfo.reduce(-infinity) { current, info in + let baseline = info.viewDimension[explicit: .lastTextBaseline] ?? -infinity + let transformBaseline = transformPoint(original: baseline + info.bounds.minY, offset: offsetY, targetBoundsMinX: 0) + return max(current, transformBaseline) + } + return lastTextBaseline != -infinity ? lastTextBaseline : nil + } + + return nil + } + + func transformPoint(original: CGFloat, offset: CGFloat, targetBoundsMinX: CGFloat) -> CGFloat { + original + offset + targetBoundsMinX + } +} + +extension _MyZStackLayout { + struct CacheInfo { + var subviewInfo: [SubViewInfo] = [] + var cropBounds: CGRect = .zero + } + + struct SubViewInfo { + let viewDimension: ViewDimensions + var bounds: CGRect = .zero + } +} + +private extension Array where Element == CGRect { + func cropBounds() -> CGRect { + let leading = self.reduce(0) { currentLeading, bounds in + Swift.min(currentLeading, bounds.minX) + } + let top = self.reduce(0) { currentTop, bounds in + Swift.min(currentTop, bounds.minY) + } + let trailing = self.reduce(0) { currentTrailing, bounds in + Swift.max(currentTrailing, bounds.maxX) + } + let bottom = self.reduce(0) { currentBottom, bounds in + Swift.max(currentBottom, bounds.maxY) + } + return .init(x: leading, y: top, width: trailing - leading, height: bottom - top) + } +} + +public struct MyZStack: View where Content: View { + let alignment: Alignment + let content: Content + public init(alignment: Alignment = .center, @ViewBuilder content: () -> Content) { + self.alignment = alignment + self.content = content() + } + + public var body: some View { + _MyZStackLayout(alignment: alignment)() { + content + } + } +} diff --git a/Example/HostingExample/Examples/AppearanceActionModifierExample.swift b/Example/SharedExample/View/AppearanceActionModifierExample.swift similarity index 100% rename from Example/HostingExample/Examples/AppearanceActionModifierExample.swift rename to Example/SharedExample/View/AppearanceActionModifierExample.swift diff --git a/Example/HostingExample/Examples/ConditionalContentExample.swift b/Example/SharedExample/View/ConditionalContentExample.swift similarity index 100% rename from Example/HostingExample/Examples/ConditionalContentExample.swift rename to Example/SharedExample/View/ConditionalContentExample.swift diff --git a/Example/HostingExample/Examples/NamespaceExample.swift b/Example/SharedExample/View/NamespaceExample.swift similarity index 100% rename from Example/HostingExample/Examples/NamespaceExample.swift rename to Example/SharedExample/View/NamespaceExample.swift