Hi,大家好,我是姜友华。前面我们设计好了
iWriter
文件格式。接下来想选实现UI部分。
iWriter
的UI部分较多,我们先来实现TabView
。其实现macOS自带了一个TabView
,长成下面的样子。我不太喜欢,所以自己弄一个。
一、标签视图
我们先众标签部分开始实现,这个没有什么,看代码就可以。
import SwiftUI
struct tagView: View {
var title: String
var onActive: (_ frame: CGRect)-> Void = {frame in }
var onClose: ()-> Void = {}
@State private var frame: CGRect = .zero
var body: some View {
HStack(spacing: 0) {
// 图标。
Image(systemName: "scroll")
.foregroundColor(.orange)
.padding(.leading, 10)
// 标题。
Text(title)
.padding(5)
// 关闭按钮。
Button {
onClose()
} label: {
Image(systemName: "xmark")
.font(.footnote)
.padding(.leading, 7)
.padding(.trailing, 10)
}
.buttonStyle(.plain)
}
.background(.white.opacity(0.1)) // 底色。
.overlay(GeometryReader { geometry in
// 获取当前frame.
Color.clear
.onAppear {
self.frame = geometry.frame(in: .global)
}
})
.onTapGesture {
onActive(frame)
}
}
}
extension tagView {
func isActive(active: Bool) -> some View {
return self.background(active ? .yellow : .white)
}
}
struct TitleItem_Previews: PreviewProvider {
static var previews: some View {
tagView(title: "Title")
}
}
// 按状态改变底色。
extension TitleView {
func isActive(active: Bool) -> some View {
return self.background(active ? .yellow : .white)
}
}
struct TitleItem_Previews: PreviewProvider {
static var previews: some View {
TitleView(title: "Title")
}
}
需要说明的有以下几点:
-
spacing: 0
:用来保证各元素之间没有间隙; - 标签按钮包含关闭按钮的目的是:只有点击
x
时才是关闭事件其余的标签激活事件。这个还需要设置HStack
的背景色.background(.white.opacity(0.1))
,不设置的话,只点文字或图片才触发事件,按钮空白处则没反应。 - 定义了两个事件:
onActive
激活事件,onClose
关闭事件。 - 拓展了一个
isActive
方法:在SwiftUI里.alert
与.sheet
也是用类似的方式来实现的,这里用来改变标签的底色。 -
.overlay
,该方法被借用为获取当前视图的frame
,里面使用了GeometryReader
。GeometryReader
是可以像下面那样使用的,但UI尺寸不好控制。
struct DefaultView: View {
var body: some View {
GeometryReader{ geometry in
let frame = geometry.frame(in: .global)
VStack{
Spacer()
Text("\(title) View")
Spacer()
}
}
}
}
二、主体视图
主体视图现在只是一个替身,后面按具体需要求来实现。
struct BodyView: View {
var title: String
var body: some View {
VStack{
Spacer()
Text("\(title) View")
Spacer()
}
}
}
struct BodyView_Previews: PreviewProvider {
static var previews: some View {
BodyView(title: "Undefine")
}
}
三、区块视图。
标签视图分两部分:一是标签栏,一是主视图。
- 标签栏:将
BodyView
数组遍历,拿出title
来实例化TitleView
,然后放在一个ScrollView
里。 - 主视图:主视图只显示
BodyView
数组里的当前视图。
先看代码。
struct BlockView: View {
@State var views: [BodyView] = [] // 视图。
@State private var current: Int = 0
@State private var scroll: Scroll = Scroll() // ScrollView的数据对象。
var body: some View {
VStack{
// 有视图则显示。
if views.count > 0 {
ScrollView(.horizontal, showsIndicators: false){
ScrollViewReader{ proxy in
HStack(spacing: 0){
// 不要使用ForEach(views.indices){index in}。
// 因为在删除views元素时会出错,提示这种模式只支持常量。
ForEach(Array(views.enumerated()), id: \.offset) { index, view in
tagView(title: view.title, onActive: { frame in
changeCurrent(index: index, frame: frame, proxy: proxy)
}, onClose: {
removeView(index: index)
})
.isActive(active: index == current)
if showDivider(index: index) {
Divider()
.background(.gray)
.frame(height: 15)
}
}
}.overlay(
// ScrollView的偏移量。
GeometryReader { geometry in
Color.clear
.preference(key: Scroll.State.self,
value: geometry.frame(in: .named("scroll")))
}
)
}
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(Scroll.State.self) { value in
self.scroll.content = value
print(value)
}
.overlay(GeometryReader { geometry in
// ScrollView的Frame。
Color.clear
.onAppear {
self.scroll.frame = geometry.frame(in: .global)
}
})
// 视图栏。
views[current]
}
}
}
// 显示分割线。
func showDivider(index: Int) -> Bool {
if index > views.count - 2 {
return false
}
if index == current || index == current - 1 {
return false
}
return true
}
// 改状态。
func changeCurrent(index: Int, frame: CGRect, proxy: ScrollViewProxy){
current = index
scroll.proxy = proxy
scroll.xGoto(index: index, count: views.count, rect: frame)
}
// 删除视图。
func removeView(index: Int){
views.remove(at: index)
if index <= current {
current = current > 0 ? current - 1 : 0
}
}
}
struct BlockView_Previews: PreviewProvider {
static var previews: some View {
BlockView(views: [
BodyView(title: "Title 1"),
BodyView(title: "Title 2"),
BodyView(title: "Title 3"),
BodyView(title: "Title 4"),
BodyView(title: "Title 5"),
BodyView(title: "Title 6"),
]).frame(width: 500, height: 300)
}
}
需要说明以下几点:
-
@State
有两个作用,一是该属性可用结构体内修改,另一个是修改完成后更新引用了它的视图。 -
ScrollViewReader
:它的作用是控制ScrollView
滚动。由这个只能按子视图的index
滚动,不能满足当前UI设计的需要求。我们这里又用上了GeometryReader
,先用它来获取偏移量、后用来获取Frame; -
.isActive(active: index == current)
在这里被用上了,使当前标签显黄色; -
onClose
关闭事件里会移除当前索引的元素。如果使用下面的处理方法会出现index
越界错误,解释的原因,这种形式接收的是常量。
ForEach(views.indices){ index in
// TODO
}
四、 控制ScrollView滚动
由于SwiftUI直接控制ScrollView滚动的方法有限,所以需要自己去处理。我这里添加了一个Scroll
结构体,其内的State
,用来保存增量,xGoto
用来控滚动,具体流程如下:
- 获取ScrollView的Frame为
Scroll.frame
; - 获取ScrollView的ContentSize与偏移量为
Scroll.Content
,注意这里的x
、y
记录的是编程量; - 点击标签后,发送标签的index,Frame到
Scroll.xGoto
,然后实现滚动。 - 滚动的策略是,可见的真接点,不可见的点后可见。
- 注意
Scroll.State
,该结构体实现了PreferenceKey
委托。PreferenceKey
是个很有用的东西,这个以后再讲,在这里的作用是记录偏移里。
struct Scroll {
var proxy: ScrollViewProxy?
var frame: CGRect = .zero
var content: CGRect = .zero
struct State: PreferenceKey {
typealias Value = CGRect
static var defaultValue = CGRect.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
let v = nextValue()
value.size.width = v.width
value.size.height = v.height
value.origin.x += v.minX
value.origin.y += v.minY
}
}
func xGoto(index: Int, count: Int, rect: CGRect){
// 当前区域完全被显示则不动。
let start = rect.origin.x + content.origin.x
let end = start + rect.width
if start > 50 && end < frame.width - 50 {
return
}
// 需要处理属部,否则点击倒数第一个后再倒数第二三,会跳出空白。
let tail = content.width - rect.origin.x - rect.width
withAnimation {
if tail > frame.width / 2 {
proxy?.scrollTo(index, anchor: .center)
return
}
proxy?.scrollTo(index + 1, anchor: .trailing)
}
}
}
好,今天就这些,我是姜友华,下次见。