四、iWriter 标签视图的实现

Hi,大家好,我是姜友华。前面我们设计好了iWriter文件格式。接下来想选实现UI部分。

iWriter的UI部分较多,我们先来实现TabView。其实现macOS自带了一个TabView,长成下面的样子。我不太喜欢,所以自己弄一个。

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")
    }
}
标签的实现

需要说明的有以下几点:

  1. spacing: 0:用来保证各元素之间没有间隙;
  2. 标签按钮包含关闭按钮的目的是:只有点击x时才是关闭事件其余的标签激活事件。这个还需要设置HStack的背景色.background(.white.opacity(0.1)),不设置的话,只点文字或图片才触发事件,按钮空白处则没反应。
  3. 定义了两个事件:onActive激活事件,onClose关闭事件。
  4. 拓展了一个isActive方法:在SwiftUI里.alert.sheet也是用类似的方式来实现的,这里用来改变标签的底色。
  5. .overlay,该方法被借用为获取当前视图的frame,里面使用了GeometryReaderGeometryReader是可以像下面那样使用的,但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)
    }
}

TagsView

需要说明以下几点:

  1. @State有两个作用,一是该属性可用结构体内修改,另一个是修改完成后更新引用了它的视图。
  2. ScrollViewReader:它的作用是控制ScrollView滚动。由这个只能按子视图的index滚动,不能满足当前UI设计的需要求。我们这里又用上了GeometryReader,先用它来获取偏移量、后用来获取Frame;
  3. .isActive(active: index == current)在这里被用上了,使当前标签显黄色;
  4. onClose关闭事件里会移除当前索引的元素。如果使用下面的处理方法会出现index越界错误,解释的原因,这种形式接收的是常量。
ForEach(views.indices){ index in
     // TODO
}

四、 控制ScrollView滚动

由于SwiftUI直接控制ScrollView滚动的方法有限,所以需要自己去处理。我这里添加了一个Scroll结构体,其内的State,用来保存增量,xGoto用来控滚动,具体流程如下:

  1. 获取ScrollView的Frame为Scroll.frame
  2. 获取ScrollView的ContentSize与偏移量为Scroll.Content,注意这里的xy记录的是编程量;
  3. 点击标签后,发送标签的index,Frame到Scroll.xGoto,然后实现滚动。
  4. 滚动的策略是,可见的真接点,不可见的点后可见。
  5. 注意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)
        }
    }
}

好,今天就这些,我是姜友华,下次见。

你可能感兴趣的:(四、iWriter 标签视图的实现)