阅读 130

SwiftUI:自适应Views

SwiftUI最强大的一个方面是它如何根据上下文进行调整,这是SwiftUI的承诺,适用于所有苹果设备,从38mm的Apple Watch到27英寸的iMac(不考虑外部显示器!)

虽然这可以节省很多时间,但有时我们想让UI声明更具适应性:在本文中,让我们看看如何做到这一点。

示例

在我们的应用中,我们希望根据可用空间调整视图。

我们定义了两种布局,一种是内容垂直堆叠,另一种是内容水平堆叠:

layoutsImage.png

在考虑如何选择布局之前,让我们定义一个通用的可重用视图AdaptiveView:

struct AdaptiveView<Content: View>: View {
  var content: Content

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  var body: some View {
    if /* condition here */ {
      HStack {
        content
      }
    } else {
      VStack {
        content
      }
    }
  }
}

我们稍后再填写这个条件

我们的示例视图将能够使用这个新的定义,其中它所需要声明的只是内容,其他一切都由AdaptiveView管理:

var body: some View {
  AdaptiveView {
    RoundedRectangle(...)
      .fill(...)
      .frame(maxHeight: 400)

    VStack {
      Text("Title")
        .bold()
        .font(.title)

      Text(...)
        .fixedSize(horizontal: false, vertical: true)
    }
  }
}

下面让我们看看如何填充AdaptiveView条件。

为了简单起见,我们将重点讨论基于水平空间可用的条件:同样的概念也适用于垂直空间。

Size classes

layoutsImage.png

所有大尺寸的iphone在横屏时都有一个标准的水平尺寸类。

每个SwiftUI视图都可以通过两个environment环境值来观察屏幕尺寸的变化:horizontalSizeClassverticalSizeClass
在SwiftUI中,它们都返回一个UserInterfaceSizeClass实例,在UIKit中UIUserInterfaceSizeClass是和它相对应的。

public enum UserInterfaceSizeClass {
  case compact
  case regular
}

在这个例子中,我们可以使AdaptiveView切换布局基于environment环境的horizontalSizeClass:

struct AdaptiveView<Content: View>: View {
  @Environment(\.horizontalSizeClass) var horizontalSizeClass
  var content: Content

  init(...) { ... }

  var body: some View {
    if horizontalSizeClass == .regular {
      // We have a "regular" horizontal screen estate: 
      // we lay the content horizontally.
      HStack {
        content
      }
    } else {
      VStack {
        content
      }
    }
  }
}

动态类型

dynamicTypeImage.png

AdaptiveView可以使用的另一种方法是基于environment的ContentSizeCategory:
UserInterfaceSizeClass告诉我们在当前方向上设备屏幕的compact/regular大小,而ContentSizeCategory告诉我们用户首选的内容大小(也就是动态类型)。

public enum ContentSizeCategory: Hashable, CaseIterable {
  case extraSmall
  case small
  case medium
  case large
  case extraLarge
  case extraExtraLarge
  case extraExtraExtraLarge
  case accessibilityMedium
  case accessibilityLarge
  case accessibilityExtraLarge
  case accessibilityExtraExtraLarge
  case accessibilityExtraExtraExtraLarge
}

我们可以在AdaptiveView中使用这些情况中的任何一种作为条件阈值,例如,我们可以切换比.large更大的布局:

struct AdaptiveView<Content: View>: View {
  @Environment(\.sizeCategory) var sizeCategory: ContentSizeCategory
  var content: Content

  init(...) { ... }

  var body: some View {
    if sizeCategory > .large {
      VStack {
        content
      }
    } else {
      HStack {
        content
      }
    }
  }
}

SwiftUI也为ContentSizeCategory 提供了isAccessibilityCategory属性,我们也可以这么使用:

struct AdaptiveView<Content: View>: View {
  @Environment(\.sizeCategory) var sizeCategory: ContentSizeCategory
  var content: Content

  init(...) { ... }

  var body: some View {
    if sizeCategory.isAccessibilityCategory {
      // When the user prefers an accessibility category, lay the content vertically.
      VStack {
        content
      }
    } else {
      HStack {
        content
      }
    }
  }
}

ContentSizeCategory实例以"accessibility"开头时,isAccessibilityCategory返回true,这似乎是一个很好的默认阈值:

当然,我们应该测试一下我们的实现,看看是否适合我们,如果不行,我们可以回到另一个阈值。

自定义阈值

到目前为止提供的方法适用于大多数视图,但是,它们也有一个很大的缺点:它们依赖于全局值。

当一个单一的AdaptiveView是屏幕的主要内容,但如果我们有多个视图,应该适应?

如果我们在这种情况下,我们可能不能依赖这些全局环境属性:相反,我们应该为每个视图分别做出决定。
这样,两个或更多的视图可以根据自己的空间和阈值进行不同的布局。
为了做到这一点,我们需要采取两步:

  1. 获取每个AdaptiveView的可用水平空间
  2. 基于这个空间创建一个条件

1. 获取可用的水平空间

幸运的是,我们已经在SwiftUI:灵活的布局中遇到了这个问题,并取得了以下结果:

struct FlexibleView: View {
  @State private var availableWidth: CGFloat = 0

  var body: some View {
    ZStack {
      Color.clear
        .frame(height: 1)
        .readSize { size in
          availableWidth = size.width
        }

      // 我们要实现的部分
    }
  }
}

我们可以在我们的通用AdaptiveView中实现它:

struct AdaptiveView<Content: View>: View {
  @State private var availableWidth: CGFloat = 0
  var content: Content

  public init(...) { ... }

  var body: some View {
    ZStack {
      Color.clear
        .frame(height: 1)
        .readSize { size in
          availableWidth = size.width
        }

      if /* condition */ {
        HStack {
          content
        }
      } else {
        VStack {
          content
        }
      }
    }
  }
}
extension View {
  func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
    background(
      GeometryReader { geometryProxy in
        Color.clear
          .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
      }
    )
    .onPreferenceChange(SizePreferenceKey.self, perform: onChange)
  }
}

private struct SizePreferenceKey: PreferenceKey {
  static var defaultValue: CGSize = .zero
  static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}

第一步完成了

基于这个空间创建一个条件

一旦我们有了可用的空间,剩下要决定的就是如何使用它。
在构建通用视图时,最好将阈值决定留给实现者,他们对视图的使用位置和实际内容有更多的了解。

由于这些原因,我们可以添加一个新的属性threshold,它将在AdaptiveView条件中使用:

struct AdaptiveView<Content: View>: View {
  @State private var availableWidth: CGFloat = 0
  var threshold: CGFloat
  var content: Content

  public init(
    threshold: CGFloat, 
    @ViewBuilder content: () -> Content
  ) {
    self.threshold = threshold
    self.content = content()
  }

  var body: some View {
    ZStack {
      Color.clear
        .frame(height: 1)
        .readSize { size in
          availableWidth = size.width
        }

      if availableWidth > threshold {
        HStack {
          content
        }
      } else {
        VStack {
          content
        }
      }
    }
  }
}

到这里,我们的自定义AdaptiveView就完成了。

验证

因为我们现在拥有了阈值,所以测试不同的阈值/布局/设备也很容易,事例如下

exp.gif
struct ContentView: View {
  @State var currentWidth: CGFloat = 0
  @State var padding: CGFloat = 8
  @State var threshold: CGFloat = 100

  var body: some View {
    VStack {
      AdaptiveView(threshold: threshold) {
        RoundedRectangle(cornerRadius: 40.0, style: .continuous)
          .fill(
            Color(red: 224 / 255.0, green: 21 / 255.0, blue: 90 / 255.0, opacity: 1)
          )
        RoundedRectangle(cornerRadius: 40.0, style: .continuous)
          .fill(
            Color.pink
          )
      }
      .readSize { size in
        currentWidth = size.width
      }
      .overlay(
        Rectangle()
          .stroke(lineWidth: 2)
          .frame(width: threshold)
      )
      .padding(.horizontal, padding)

      Text("Current width: \(Int(currentWidth))")
      HStack {
        Text("Threshold: \(Int(threshold))")
        Slider(value: $threshold, in: 0...500, step: 1) { Text("") }
      }
      HStack {
        Text("Padding:")
        Slider(value: $padding, in: 0...500, step: 1) { Text("") }
      }
    }
    .padding()
  }
}

完整的AdaptiveView代码:

多种布局

到目前为止,我们看到的例子根据我们的条件调整布局方向,但是这不是唯一的用例,例如,我们可以使用类似的方法来显示/隐藏UI的一部分:

socialImage.png

事例代码:

struct SocialSignInView: View {
  @State private var availableWidth: CGFloat = 0

  private var buttonMode: SignInButton.Mode {
    availableWidth > 500 ? .regular : .compact
  }

  var body: some View {
    ZStack {
      Color.clear
        .frame(height: 1)
        .readSize { size in
          availableWidth = size.width
        }

      HStack {
        SignInButton(action: {}, tintColor: .appleTint, imageName: "apple", mode: buttonMode)
        SignInButton(action: {}, tintColor: .googleTint, imageName: "google", mode: buttonMode)
        SignInButton(action: {}, tintColor: .twitterTint, imageName: "twitter", mode: buttonMode)
      }
    }
  }
}

struct SocialSignInView_Previews: PreviewProvider {
  static var previews: some View {
    Group {
      SocialSignInView()
        .previewLayout(.fixed(width: 568, height: 320))
      SocialSignInView()
        .previewLayout(.fixed(width: 320, height: 528))
    }
  }
}

SignInButton:

extension Color {
  static let appleTint = Color.black
  static let googleTint = Color(red: 222 / 255.0, green: 82 / 255.0, blue: 70 / 255.0)
  static let twitterTint = Color(red: 29 / 255.0, green: 161 / 255.0, blue: 242 / 255.0)
}

struct SignInButton: View {

  enum Mode {
    case regular
    case compact
  }

  var action: () -> Void
  var tintColor: Color
  var imageName: String
  var mode: Mode

  var body: some View {
    Button(action: action) {
      switch mode {
      case .compact:
        Circle()
          .fill(tintColor)
          .overlay(Image(imageName))
          .frame(width: 44, height: 44)
      case .regular:
        HStack {
          Text("Sign in with")
          Image(imageName)
        }
        .padding()
        .background(
          Capsule()
            .fill(tintColor)
        )
      }
    }
    .foregroundColor(.white)
  }
}

struct SignInButton_Previews: PreviewProvider {
  static var previews: some View {
    Group {
      SignInButton(action: {}, tintColor: .appleTint, imageName: "apple", mode: .regular)
      SignInButton(action: {}, tintColor: .appleTint, imageName: "apple", mode: .compact)
      SignInButton(action: {}, tintColor: .googleTint, imageName: "google", mode: .regular)
      SignInButton(action: {}, tintColor: .googleTint, imageName: "google", mode: .compact)

      SignInButton(action: {}, tintColor: .twitterTint, imageName: "twitter", mode: .regular)
      SignInButton(action: {}, tintColor: .twitterTint, imageName: "twitter", mode: .compact)
    }
    .previewLayout(.sizeThatFits)
  }
}

总结

SwiftUI尽其所能地适应每一个给定的场景:让框架来做所有繁重的工作是完全没问题的,但是如果我们多做一点工作,就可以帮助我们提供更好的用户体验。

作者:猪猪行天下

原文链接:https://www.jianshu.com/p/ccbe08ac7970

文章分类
后端
文章标签
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐