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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容