FrameUp Logo

Swift Compatibility Platform Compatibility License - MIT Version GitHub last commit Mastodon Twitter

概述

一组用于辅助布局的 SwiftUI 工具。

一些与小组件相关的工具

适用于 iOS 14+15、macOS 11+12、watchOS 7+8 和 tvOS 14+15 的其他 SwiftUI 工具

演示应用

Example 文件夹中有一个应用程序,演示了此软件包的功能。

安装和使用

此软件包兼容 iOS 14+、macOS 11+、watchOS 7+、tvOS 14+ 和 visionOS。

  1. 在 Xcode 中,转到 File -> Add Packages
  2. 粘贴仓库的 URL:https://github.com/ryanlintott/FrameUp 并按版本选择。
  3. 使用 import FrameUp 导入软件包

是否已准备好用于生产环境?

实际上取决于您。我目前在我的 Old English Wordhord 应用程序 中使用了此软件包。

此外,如果您发现错误或想要新功能,请添加 issue,我会尽快回复您。

支持此项目

FrameUp 是开源且免费的,但如果您喜欢使用它,请考虑支持我的工作。

ko-fi


功能特性

布局 (Layouts)

*iOS 16+、macOS 13+、watchOS 9+、tvOS 16+

如果您的目标操作系统较旧且不支持 SwiftUI Layout,请使用 FULayout 等效项。

HFlowLayout

一个 Layout,用于在水平行中排列视图,从一行流向下一行,具有可调整的水平和垂直间距,并支持水平和垂直对齐,包括一个对齐方式为 justified 的对齐,它将均匀地间隔已完成行中的元素。

每行的高度将由最高的元素决定。整体框架尺寸将适应布局内容的大小。

HFlowLayout {
    ForEach(["Hello", "World", "More Text"], id: \.self) { item in
        Text(item.value)
    }
}

VFlowLayout

一个 Layout,用于在垂直列中排列视图,从一列流向下一列,具有可调整的水平和垂直间距,并支持水平和垂直对齐,包括一个对齐方式为 justified 的对齐,它将均匀地间隔已完成列中的元素。

每列的宽度将由最宽的元素决定。整体框架尺寸将适应布局内容的大小。

VFlowLayout {
    ForEach(["Hello", "World", "More Text"], id: \.self) { item in
        Text(item.value)
    }
}

VMasonryLayout

一个 Layout,通过将每个视图添加到最短的列中,将视图排列成设定数量的列。

VMasonryLayout(columns: 3) {
    ForEach(["Hello", "World", "More Text"], id: \.self) { item in
        Text(item.value)
    }
}

HMasonryLayout

一个 Layout,通过将每个视图添加到最短的行中,将视图排列成设定数量的行。

HMasonryLayout(rows: 3) {
    ForEach(["Hello", "World", "More Text"], id: \.self) { item in
        Text(item.value)
    }
}

LayoutThatFits

使用首个适合所提供轴的布局,从布局偏好数组中创建布局。

LayoutThatFits(in: .horizontal, [HStackLayout(), VStackLayout()]) {
    Color.green.frame(width: 50, height: 50)
    Color.yellow.frame(width: 50, height: 200)
    Color.blue.frame(width: 50, height: 100)
}

AutoRotatingView

*仅限 iOS

一个视图,如果当前设备方向在允许的方向数组中,则将任何视图旋转以匹配当前设备方向。这对于允许全屏图像视图在仅限纵向模式的应用程序中使用横向模式最有用。它也可用于限制方向,例如在允许纵向模式的应用程序中仅限横向模式。旋转可以是动画的。

AutoRotatingView([.portrait, .landscapeLeft, .landscapeRight], animation: .default) {
    Image("MyFullscreenImage")
        .resizable()
        .scaledToFit()
}

Frame 调整

WidthReader

一个视图,它获取可用宽度并将此测量值提供给其内容。与“GeometryReader”不同,此视图不会占用所有可用高度,而是会适应内容的高度。

在垂直滚动视图中很有用,您希望在不指定框架高度的情况下测量宽度。

ScrollView {
    WidthReader { width in
        HStack(spacing: 0) {
            Text("This text frame is set to 70% of the width.")
                .frame(width: width * 0.7)
                .background(Color.green)

            Circle()
        }
    }
    .foregroundColor(.white)
    .background(Color.blue)

    Text("The WidthReader above does not have a fixed height and will grow to fit the content.")
        .padding()
}

HeightReader

一个视图,它获取可用高度并将此测量值提供给其内容。与“GeometryReader”不同,此视图不会占用所有可用宽度,而是会适应内容的宽度。

在水平滚动视图中很有用,您希望在不指定框架宽度的情况下测量高度。

ScrollView(.horizontal) {
    HeightReader { height in
        VStack(spacing: 0) {
            Text("This\ntext\nframe\nis\nset\nto\n70%\nof\nthe\nheight.")
                .frame(height: height * 0.7)
                .background(Color.green)

            Circle()
        }
        .foregroundColor(.white)
        .background(Color.blue)

        Text("\nThe\nHeightReader\nto\nthe\nleft\ndoes\nnot\nhave\na\nfixed\nwidth\nand\nwill\ngrow\nto\nfit\nthe\ncontent.")
            .padding()
    }
}

.onSizeChange(perform:)

添加一个操作,以便在父视图尺寸值更改时执行。

struct OnSizeChangeExample: View {
    @State private var size: CGSize = .zero
    
    var body: some View {
        Text("Hello, World!")
            .padding(100)
            .background(Color.blue)
            .onSizeChange { size in
                self.size = size
            }
            .overlay(Text("size: \(size.width) x \(size.height)"), alignment: .bottom)
    }
}

equalWidthPreferred

在具有 .equalWidthContainer() 的视图中,具有 .equalWidthPreferred() 的视图将具有等于最大视图的宽度。如果空间有限,视图将平均缩小,每个视图缩小到适合内容的最小尺寸。它适用于 HStackVStack 甚至自定义布局,如 HFlow

当您希望窗口上的所有按钮都具有相同的宽度,同时仍然允许一些收缩时,这在 macOS 中尤其有效。

HStack {
    Button { } label: {
        Text("More Information")
            .equalWidthPreferred()
    }
    Spacer()
    
    Button { } label: {
        Text("Cancel")
            .equalWidthPreferred()
    }
    
    Spacer()
    
    Button { } label: {
        Text("OK")
            .equalWidthPreferred()
    }
}
.equalWidthContainer()
.frame(maxWidth: 400)
.padding()

equalHeightPreferred

在具有 .equalHeightContainer() 的视图中,具有 .equalHeightPreferred() 的视图将具有等于最大视图的高度。如果空间有限,视图将平均缩小,每个视图缩小到适合内容的最小尺寸。它适用于 HStackVStack 甚至自定义布局,如 VFlow

HStack {
    Group {
        Text("Here's something with some text")
        
        Text("And more")
    }
    .foregroundColor(.white)
    .padding()
    .equalHeightPreferred()
    .background(Color.blue.cornerRadius(10))
}
.equalHeightContainer()
.frame(maxWidth: 200)

keyboardHeight

一个环境变量,当 iOS 键盘出现和消失时,它将以动画方式更新。对于非 iOS 平台,它始终为零。

Animation.keyboard 作为键盘动画曲线的近似值添加,并由 keyboardHeight 使用。

为了使用 keyboardHeight,您首先需要将其添加到视图层次结构的顶部,以便它可以查看整个框架。它将在背景层上使用 GeometryReader 来测量键盘,因此请确保视图正在使用所有可用高度。

struct ContentView: View {
    var body: some View {
        MyView()
            .frame(maxHeight: .infinity)
            .keyboardHeightEnvironmentValue()
    }
}

当您想要访问 keyboardHeight 时,请使用环境变量。如果您使用它来调整应避开键盘的视图的位置,请直接使用 keyboardHeight,并确保视图忽略键盘安全区域。

struct MyView: View {
    @Environment(\.keyboardHeight) var keyboardHeight
    @State private var text = ""
    
    var body: some View {
        TextField("Moves with keyboard", text: $text)
            .keyboardHeightEnvironmentValue()
            .padding(.bottom, keyboardHeight == 0 ? 100 : keyboardHeight)
            .ignoresSafeArea(.keyboard)
    }
}

.relativePadding(edges:, lengthFactor:)

向视图的指定边缘添加相对于视图大小的内边距量。宽度用于 .leading/.trailing,高度用于 .top/.bottom

负值可用于重叠内容。

Text("This text will have padding based on the width and height of its frame.")
    .relativePadding([.leading, .top], 0.2)

ScaledView

一个视图修饰符,使用 scaleEffect 缩放视图以匹配框架尺寸。

视图必须具有固有内容尺寸或提供特定的框架尺寸。最终框架尺寸可能会因所选模式而异。

使用 ScaleMode 限制视图,使其只能增长/缩小或两者都可。

在这些视图扩展中使用

OverlappingImage

一个图像视图,可以在其框架边缘重叠内容。

图像可以在垂直轴或水平轴上重叠,但不能同时在两个轴上重叠。

请务必考虑间距并使用 zIndex 将图像放置在内容的前面或后面。

VStack(spacing: 0) {
    Text("Overlapping Image")
        .font(.system(size: 50))

    OverlappingImage(Image(systemName: "star.square"), aspectRatio: 1.0, top: 0.1, bottom: 0.25)
        .padding(.horizontal, 50)
        .zIndex(1)

    Text("The image above will overlap content above and below.")
        .padding(20)
}

文本 (Text)

unclippedTextRenderer

*iOS 18+、macOS 15+、watchOS 11+、tvOS 18+、visionOS 2+

SwiftUI Text 有一个无法调整的裁剪框架,偶尔会裁剪渲染的文本。此修饰符应用一个 UnclippedTextRenderer,它会移除此裁剪框架。

如果使用另一个文本渲染器,则此修饰符是不必要的,因为所有文本渲染器都会移除裁剪框架。

Text("f")
    .font(.custom("zapfino", size: 30))
    .unclippedTextRenderer()

SmartScrollView

*仅限 iOS

一个具有额外功能的 ScrollView。

SmartScrollView(.vertical, showsIndicators: true, optionalScrolling: true, shrinkToFit: true) {
    // Content here
} onScroll: { edgeInsets in
    // Runs when edge insets change
}

局限性

FlippingView

FlippingView

一个双面视图,可以通过点击或滑动翻转。

轴、锚点、透视、翻转的拖动距离、点击翻转的动画等都可以自定义。

对于 visionOS,可能需要稍微不同的初始化程序,并且翻转将在 3D 空间中发生。如果相反,您想要在平面视图上获得透视效果,则可以使用 PerspectiveFlippingView

FlippingView(flips: $flips) {
    Color.blue.overlay(Text("Up"))
} back: {
    Color.red.overlay(Text("Back"))
}

rotation3DEffect(angle:axis:anchor:anchorZ:perspective:backsideFlip:back:)

*在 visionOS 中已弃用 渲染视图的内容,就好像它围绕指定的轴在三维空间中旋转一样,并带有一个闭包,其中包含要在背面显示的不同的视图。

下面的示例是一个具有两面的视图。一面是蓝色,上面写着“Front”,背面是红色,上面写着“Back”。更改角度将显示每个面,因为它是可见的。

Color.blue.overlay(Text("Front"))
    .rotation3DEffect(angle) {
        Color.red.overlay(Text("Back"))
    }

rotation3DEffect(angle:axis:anchor:backsideFlip:back:)

*visionOS 围绕给定的旋转轴在三维空间中旋转此视图的渲染输出,并带有一个闭包,其中包含背面不同的视图。需要最小厚度来偏移两个视图,以确保面向用户的面在顶部渲染。

Color.blue.overlay(Text("Front"))
    .rotation3DEffect(angle) {
        Color.red.overlay(Text("Back"))
    }

perspectiveRotationEffect(angle:axis:anchor:anchorZ:perspective:backsideFlip:back:)

*visionOS 渲染视图的内容,就好像它围绕指定的轴在三维空间中旋转一样,并带有一个闭包,其中包含要在背面显示的不同的视图。该视图实际上并未在 3D 空间中旋转。

Color.blue.overlay(Text("Front"))
    .rotation3DEffect(angle) {
        Color.red.overlay(Text("Back"))
    }

TabMenu

*仅限 iOS

可自定义的标签菜单栏视图,旨在模仿 iPhone 上默认标签菜单栏的样式。提供的图像或视图和名称用于遮罩另一个提供的视图,这通常是一种颜色。

功能特性

let items = [
    TabMenuItem(icon: AnyView(Circle().stroke().overlay(Text("i"))), name: "Info", tab: 0),
    TabMenuItem(image: Image(systemName: "star"), name: "Favourites", tab: 1),
    TabMenuItem(image: Image(systemName: "bookmark"), name: "Categories", tab: 2),
    TabMenuItem(image: Image(systemName: "books.vertical"), name: "About", tab: 3)
]

TabMenuView(selection: $selection, items: items) { isSelected in
    Group {
        if isSelected {
            Color.accentColor
        } else {
            Color(.secondaryLabel)
        }
    }
} onReselect: {
    NamedAction("Reselect") {
        print("TabMenu item \(selection) reselected")
    }
} onDoubleTap: {
    NamedAction("Double Tap") {
        print("TabMenu item \(selection) doubletapped")
    }
}

小组件 (Widgets)

AccessoryInlineImage

一个图像,将被缩放并调整渲染模式以在 accessoryInline 小组件内部工作。图像将缩放以适应框架并应用模板渲染模式。

在 Label 的 icon 属性内部使用。

Label {
    Text("Label Text")
} icon: {
    AccessoryInlineImage("myImage")
}

WidgetSize

一个类似于 WidgetFamily 的枚举,但按设备返回小组件框架尺寸,并且不需要 WidgetKit,因此可以在您的主 iOS 或 macOS 应用程序内部使用。

`sizeForiPhone(screenSize:)

根据提供的屏幕尺寸返回小组件的尺寸。

`sizeForiPad(screenSize:, target:)

根据提供的屏幕尺寸,返回小组件的设计画布或主屏幕尺寸(取决于提供的目标)。在 iPad 上,小组件内容被放置在设计画布上,然后缩放以适应主屏幕尺寸。(WidgetDemoFrame 将为您执行此缩放)

supportedSizesForCurrentDevice (仅限 iOS)

根据设备类型和 iOS 版本返回支持的小组件尺寸数组。

sizeForCurrentDevice (仅限 iOS)

根据当前设备返回小组件的尺寸。

所有小组件尺寸信息均来自:Apple - 人机界面指南

WidgetDemoFrame

创建针对提供的屏幕尺寸或当前设备(仅限 iOS)调整大小的小组件框架。用于从应用程序内部显示示例小组件。

圆角尺寸默认为 20,可能与实际小组件圆角半径不同。

对于 iPad,小组件视图使用设计尺寸,并使用 ScaledView 缩放到较小的主屏幕尺寸。此演示框架使用相同的缩放来正确预览小组件。所有尺寸都适用于所有设备和所有版本的 iOS(甚至 iOS 14.0 上的 iPhone 上的 extraLarge)。

WidgetDemoFrame(.medium, cornerRadius: 20) { size, cornerRadius in
    Text("Demo Widget")
}

其他工具

Proportionable

一个协议,添加了有用的参数,如 aspectFormataspectRatiominDimensionmaxDimension

用于具有 widthheight 属性的类型,如 CGSize

如何在您的应用程序中添加一致性

extension CGSize: Proportionable { }

frame(size:,alignment:)

frame(width:,height:,alignment:) View 修饰符的替代方案,它接受 CGSize 参数。


iOS 14+15 的功能特性

FULayout

如果您喜欢 SwiftUI Layout 协议,但您需要以不支持它的旧操作系统为目标,那么 FULayout 协议可能是您的答案!

FULayout 的工作方式与 SwiftUI Layout 相同。主要区别在于,在初始化时,它将需要 maxWidthmaxHeight 参数,以便了解可用空间。这可以通过 GeometryReader 或此软件包中的 WidthReaderHeightReader 提供。

ViewBuilder

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 FULayoutcallAsFunction() 与视图构建器一起使用,因此您可以像使用 SwiftUI Layout 一样使用它。

VFlow(maxWidth: 200) {
    Text("Hello")
    Text("World")
}

注意:此方法在底层使用了 Apple 的私有协议 _VariadicView。Apple 可能会更改实现,这存在很小的风险,因此如果这让您担心,请使用下面的方法 2。

.forEach()

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 此方法的工作方式与 ForEach() 非常相似。

MyFULayout().forEach(["Hello", "World"], id: \.self) { item in
        Text(item.value)
    }
}

FULayouts

HFlow

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 FULayout 等效于 HFlowLayout

一个 FrameUp FULayout,用于在水平行中排列视图,从一行流向下一行,具有可调整的水平和垂直间距,并支持水平和垂直对齐,包括一个对齐方式为 justified 的对齐,它将均匀地间隔已完成行中的元素。

每行的高度将由该行中最高的视图决定。

WidthReader { width in
    HFlow(maxWidth: width) {
        ForEach(["Hello", "World", "More Text"], id: \.self) { item in
            Text(item.value)
        }
    }
}

VFlow

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 FULayout 等效于 VFlowLayout

一个 FrameUp FULayout,用于在垂直列中排列视图,从一列流向下一列,具有可调整的水平和垂直间距,并支持水平和垂直对齐,包括一个对齐方式为 justified 的对齐,它将均匀地间隔已完成列中的元素。

每列的宽度将由最宽的元素决定。

WidthReader { width in
    VFlow(maxWidth: width) {
        ForEach(["Hello", "World", "More Text"], id: \.self) { item in
            Text(item.value)
        }
    }
}

HMasonry

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 FULayout 等效于 HMasonryLayout

一个 FrameUp FULayout,通过将每个视图添加到最短的行中,将视图排列成设定数量的行。

HeightReader { height in
    HMasonry(columns: 3, maxHeight: height) {
        ForEach(["Hello", "World", "More Text"], id: \.self) { item in
            Text(item.value)
                .frame(maxHeight: .infinity, alignment: .center)
        }
    }
}

VMasonry

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 FULayout 等效于 VMasonryLayout

一个 FrameUp FULayout,通过将每个视图添加到最短的行中,将视图排列成设定数量的行。

WidthReader { width in
    VMasonry(columns: 3, maxWidth: width) {
        ForEach(["Hello", "World", "More Text"], id: \.self) { item in
            Text(item.value)
                .frame(maxWidth: .infinity, alignment: .center)
        }
    }
}

FULayoutThatFits

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 FULayout 等效于 LayoutThatFits

一个 FULayout,它选择第一个提供的、适合提供的 maxWidth、maxHeight 或两者的布局。当在 HStackFULayoutVStackFULayout 之间切换时,这最有帮助,因为内容只需要提供一次,甚至在堆栈更改时也会进行动画处理。

FULayoutThatFits(
    maxWidth: maxWidth,
    layouts: [
        HStackFULayout(maxHeight: 1000),
        VStackFULayout(maxWidth: maxWidth)
    ]
) {
    Color.green.frame(width: 50, height: 50)
    Color.yellow.frame(width: 50, height: 200)
    Color.blue.frame(width: 50, height: 100)
}

FUViewThatFits

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 FULayout 等效于 SwiftUI ViewThatFits

一个 FULayout,它呈现第一个适合提供的 maxWidth、maxHeight 或两者的视图,具体取决于使用了哪些参数。

由于此视图无法测量可用空间,因此需要使用 GeometryReaderWidthReaderHeightReader 传入 maxWidth 和/或 maxHeight 参数。

WidthReader { width in
    FUViewThatFits(maxWidth: width) {
        Group {
            Text("This layout will pick the first view that fits the available width.")
            Text("Maybe this?")
            Text("OK!")
        }
        .fixedSize(horizontal: true, vertical: false)
    }
}

(在此示例中需要使用 .fixedSize,否则第一个视图将通过截断文本自动适应)

FULayout 堆栈 (Stacks)

可以包装在 AnyFULayout 中的替代堆栈布局,然后在它们之间进行动画切换。当您想要根据可用空间在 VStack 和 HStack 之间切换时很有用。

HStackFULayout

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 类似于 HStack,但不能使用 Spacer(),并且内容将始终在水平轴上使用固定尺寸。

VStackFULayout

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 类似于 VStack,但不能使用 Spacer(),并且内容将始终在垂直轴上使用固定尺寸。

ZStackFULayout

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 类似于 ZStack,但内容将始终在垂直和水平轴上使用固定尺寸。

AnyFULayout

*已弃用 iOS 16、macOS 13、watchOS 7、tvOS 14、visionOS 1 FULayout 等效于 SwiftUI AnyLayout

一个类型擦除的 FrameUp 布局,可用于包装多个布局并在它们之间进行动画切换。

struct AnyFULayoutSimple: View {
    let isVStack: Bool
    let maxSize: CGSize
    
    var layout: any FULayout {
        isVStack ? VStackFULayout(maxWidth: maxSize.width) : HStackFULayout(maxHeight: maxSize.height)
    }
    
    var body: some View {
        AnyFULayout(layout) {
            Text("First")
            Text("Second")
            Text("Third")
        }
        .animation(.spring(), value: isVStack)
    }
}

自定义 FULayout

FrameUp FULayout 协议要求您定义哪些轴是固定的、最大项目尺寸以及一个接受视图尺寸并输出视图偏移量的函数。

下面是一个示例布局,它在中心线的左右两侧排列视图。

struct CustomFULayout: FULayout {
    /// Add parameters here to adjust layout
    
    /// Define these required parameters
    var fixedSize: Axis.Set = .horizontal
    var maxItemWidth: CGFloat? { maxWidth }
    var maxItemHeight: CGFloat? = nil
    
    func contentOffsets(sizes: [Int : CGSize]) -> [Int : CGPoint] {
        /// Write code that uses the dictionary of sizes and your parameters to output a dictionary of offsets from the top left corner.
    }
}

LayoutFromFULayout

如果您创建了 FULayout,则可以使用它轻松创建 SwiftUI Layout

struct CustomLayout: LayoutFromtFULayout {
    /// Add parameters here to adjust layout
    
    /// Add this function that will create the associated FULayout
    func fuLayout(maxSize: CGSize) -> CustomFULayout {
        CustomFULayout(
            /// Pass parameters through to FULayout using maxSize to help define the maximum item size.
        )
    }
}

TagView

一个较旧且更简单的 HFlow 版本,并非基于 FULayout

TagView

一个视图,它基于元素数组从左到右创建视图,并在需要时添加行。每行的高度将由最高的元素决定。

警告:在 ScrollView 中不起作用。

TagView(elements: ["One", "Two", "Three"]) { element in
    Text(element)
}

TagViewForScrollView

一个视图,它基于元素数组从左到右创建视图,并在需要时添加行。每行的高度将由最高的元素决定。

必须提供最大宽度,但可以使用 WidthReader 获取该值。

WidthReader { width in
    TagView(maxWidth: width, elements: ["One", "Two", "Three"]) { element in
        Text(element)
    }
}

小组件错误修复 (bugfixes)

WidgetRelativeShape

*仅限 iOS

ContainerRelativeShape 的重新缩放版本,用于修复在运行 iOS 15 及更早版本的 iPad 上圆角半径的一个错误。对于 iOS 16 及更高版本,它的作用与 ContainerRelativeShape 完全相同。

此示例具有蓝色背景,并带有 1 个点的内边距。在运行 iOS 15 或更早版本的 iPad 上,红色背景将显示在角上,因为圆角半径不匹配。

Text("Example widget")
    .background(.blue)
    .clipShape(WidgetRelativeShape(.systemSmall))
    .background(
        ContainerRelativeShape()
            .fill(.red)
    )
    .padding(1)