在一个栈上设置内边距,其子视图可能是空视图
假设我们有一个OptionalView,当某个条件成立时它会渲染一个视图(这个条件是什么,我们并不知道),否则什么也不渲染(将主体解析为空视图)。
struct OptionalView: View {
private var random: Int {
(1...5).randomElement()!
}
var body: some View {
if random == 1 { // Intentional, we cannot check in parent if this condition is true.
Text("Hello There")
}
}
}
如果我们把一些OptionalView包裹在一个栈视图中(如VStack或 HStack):
VStack(spacing: 10) {
OptionalView()
OptionalView()
OptionalView()
}
.border(.red)
当某些OptionalView的主体在运行时因条件被解析为空视图时,栈不会为它们腾出额外的空间。
到此为止还算顺利。但是如果我们需要对该栈应用内边距,如下:
VStack(spacing: 10) {
OptionalView()
OptionalView()
OptionalView()
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
.border(.red)
我们会发现即使没有渲染任何OptionalView,内边距仍然会生效。即使栈是空的,也会存在留白。
Note:对于完全没有视图的Stack也是如此:
VStack(spacing: 10){
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
.border(.red)
如何让内边距仅在栈中至少渲染出一个视图时才应用?
使用Group不太可行,因为栈中各视图之间的间距会变化,且顶部和底部的内边距需要始终应用到栈中的第一个和最后一个视图,而对于动态渲染的视图来说,任意视图都可能成为第一或最后一个,因此不可能做到。
由于OptionalView的条件不可预测,我们不能对栈有条件地应用内边距。
我解决的一个办法是使用偏好键(preference key):
struct Paddable: PreferenceKey {
static var defaultValue: Bool { false }
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = value || nextValue()
}
}
struct PadOnlyWhenPaddable: ViewModifier {
@State private var paddable: Bool = false
let edges: Edge.Set
let padding: CGFloat?
func body(content: Content) -> some View {
content
.onPreferenceChange(Paddable.self) {
self.paddable = $0
}
.padding(edges, paddable ? padding : 0)
}
}
extension View {
func paddingWhenPaddable(_ edges: Edge.Set = .all, _ padding: CGFloat? = nil) -> some View {
modifier(PadOnlyWhenPaddable(edges: edges, padding: padding))
}
func paddable() -> some View {
preference(key: Paddable.self, value: true)
}
}
然后在栈中,我会将paddable修饰符应用到那些按条件渲染的视图上:
VStack(spacing: 10) {
OptionalView()
.paddable()
OptionalView()
.paddable()
OptionalView()
.paddable()
OptionalView()
.paddable()
}
.paddingWhenPaddable(.horizontal, 20)
.paddingWhenPaddable(.vertical, 24)
.border(.red)
因为如果视图被解析为空视图时,该偏好将被忽略,所以只有栈中至少渲染出一个视图时,内边距才会生效。
还有没有其他更好的方式在不使用偏好键的情况下实现?目标:iOS 15+
解决方案
你可以自建一个容器,当内部没有视图时会解析为空视图。
struct MyVStack<Content: View>: View {
let alignment: HorizontalAlignment
let spacing: CGFloat?
let content: Content
init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) {
self.alignment = alignment
self.spacing = spacing
self.content = content()
}
var body: some View {
Group(subviews: content) { subviews in
if !subviews.isEmpty {
VStack(alignment: alignment, spacing: spacing) {
content
}
}
}
}
}
当content为空时,Group也会为空,内边距不会生效。
MyVStack(spacing: 10) {
OptionalView()
OptionalView()
OptionalView()
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
.border(.red)
备选方案
我在类似“dashboard”类型的应用中也做了类似处理,数据可能是“无效”的。
我认为protocol方法是一个不错的选项。
protocol OptionalView: CaseIterable, RawRepresentable, RawRepresentable where RawValue: Hashable {
associatedtype V: View
func isValid() -> Bool
@ViewBuilder var views: V { get }
}
然后你可以对body中的视图执行filter。
struct ViewDisplay<C>: View where C: OptionalView{
var validViews: [C] {
C.allCases.filter({ view in
view.isValid()
})
}
var body: some View{
let validViews = validViews
switch validViews.isEmpty{
case false:
ForEach(validViews, id:\.rawValue) { view in
view.views
}
case true:
Text("No valid views.")
}
}
}
一个简单的例子是位置信息数据,其数值可能会因为其他数值(例如不同的精度)而无效。
//Shared protocol
protocol OptionalView: CaseIterable, RawRepresentable, RawRepresentable where RawValue: Hashable {
associatedtype S
func isValid(_ something: S) -> Bool
}
//Enum that lists all the views available for CLLocation
enum LocationSubViews: Int, OptionalView {
typealias S = CLLocation
case vert
case horz
case speed
func isValid(_ something: S) -> Bool {
switch self {
case .vert:
return something.verticalAccuracy > 100
case .horz:
return something.horizontalAccuracy > 0
case .speed:
return something.speedAccuracy > 0
}
}
}
struct ViewDisplay: View {
typealias C = LocationSubViews
let value: C.S
//filter view views based on validity
var validViews: [C] {
C.allCases.filter({ view in
view.isValid(value)
})
}
var body: some View{
let validViews = validViews
switch validViews.isEmpty{
case false:
ForEach(validViews, id:\.rawValue) { view in
//display the valid views
switch view {
case .vert:
Text("valid vertical")
case .horz:
Text("valid horizontal")
case .speed:
Text("valid speed")
}
}
case true:
Text("No valid views.")
}
}
}