Как сделать просмотр размера другого представления в SwiftUI

Я пытаюсь воссоздать часть приложения Twitter для iOS, чтобы изучить SwiftUI, и мне интересно, как динамически изменить ширину одного представления, чтобы оно стало шириной другого представления. В моем случае, чтобы подчеркивание было такой же ширины, как текстовое представление.

Я приложил скриншот, чтобы попытаться лучше объяснить, о чем я говорю. Спасибо за любую помощь, спасибо!

Также вот код, который у меня есть:

import SwiftUI

struct GridViewHeader : View {

    @State var leftPadding: Length = 0.0
    @State var underLineWidth: Length = 100

    var body: some View {
        return VStack {
            HStack {
                Text("Tweets")
                    .tapAction {
                        self.leftPadding = 0

                }
                Spacer()
                Text("Tweets & Replies")
                    .tapAction {
                        self.leftPadding = 100
                    }
                Spacer()
                Text("Media")
                    .tapAction {
                        self.leftPadding = 200
                }
                Spacer()
                Text("Likes")
            }
            .frame(height: 50)
            .padding(.horizontal, 10)
            HStack {
                Rectangle()
                    .frame(width: self.underLineWidth, height: 2, alignment: .bottom)
                    .padding(.leading, leftPadding)
                    .animation(.basic())
                Spacer()
            }
        }
    }
}

Ответ 1

Я написал подробное объяснение об использовании GeometryReader, настройках просмотра и привязках. Код ниже использует эти понятия. Дополнительную информацию о том, как они работают, смотрите в этой статье, которую я разместил: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/

Решение, приведенное ниже, будет правильно анимировать подчеркивание:

enter image description here

Я изо всех сил пытался сделать эту работу, и я согласен с вами. Иногда вам просто нужно пройти вверх или вниз по иерархии, некоторую информацию о кадрировании. Фактически, сеанс 237 WWDC2019 (Создание пользовательских представлений с помощью SwiftUI) объясняет, что представления непрерывно сообщают свои размеры. Это в основном говорит, что Родитель предлагает размер ребенку, дети решают, как они хотят расположить себя и общаться с родителем. Как они это делают? Я подозреваю, что anchorPreference как-то связан с этим. Однако это очень неясно и пока не документировано. API раскрывается, но понимает, как работают эти прототипы длинных функций... что, черт возьми, у меня сейчас нет времени.

Я думаю, что Apple оставила это недокументированным, чтобы заставить нас переосмыслить всю структуру и забыть о "старых" привычках UIKit и начать мыслить декларативно. Однако бывают времена, когда это необходимо. Вы когда-нибудь задумывались, как работает модификатор фона? Я хотел бы увидеть эту реализацию. Это многое бы объяснило! Я надеюсь, что Apple документирует предпочтения в ближайшем будущем. Я экспериментировал с пользовательским PreferenceKey, и он выглядит интересно.

Теперь вернемся к вашей конкретной потребности, мне удалось решить это. Вам нужны два измерения (позиция х и ширина текста). Один мне кажется честным и откровенным, другой кажется чем-то вроде взлома. Тем не менее, это работает отлично.

Положение х текста я решил, создав собственное горизонтальное выравнивание. Больше информации о том сеансе проверки 237 (в минуту 19:00). Хотя я рекомендую вам посмотреть все это, оно проливает много света на то, как работает процесс верстки.

Однако шириной я не очень горжусь... ;-) Требуется DispatchQueue, чтобы избежать обновления вида во время отображения. ОБНОВЛЕНИЕ: я исправил это во второй реализации ниже

Первая реализация

import SwiftUI

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> Length {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}


struct GridViewHeader : View {

    @State private var activeIdx: Int = 0
    @State private var w: [Length] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 0))
                Spacer()
                Text("Tweets & Replies").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 1))
                Spacer()
                Text("Media").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 2))
                Spacer()
                Text("Likes").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 3))
                }
                .frame(height: 50)
                .padding(.horizontal, 10)
            Rectangle()
                .alignmentGuide(.underlineLeading) { d in d[.leading]  }
                .frame(width: w[activeIdx],  height: 2)
                .animation(.basic())
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    @Binding var widths: [Length]
    let idx: Int

    func body(content: Content) -> some View {
        Group {
            if activeIdx == idx {
                content.alignmentGuide(.underlineLeading) { d in
                    DispatchQueue.main.async { self.widths[self.idx] = d.width }

                    return d[.leading]
                }.tapAction { self.activeIdx = self.idx }

            } else {
                content.tapAction { self.activeIdx = self.idx }
            }
        }
    }
}

Обновление: лучшая реализация без использования DispatchQueue

Мое первое решение работает, но я не слишком гордился тем, как ширина передается в подчеркивание.

Я нашел лучший способ достичь того же. Оказывается, модификатор background очень мощный. Это гораздо больше, чем модификатор, который позволяет вам украшать фон представления.

Основные шаги:

  1. Используйте Text("text").background(TextGeometry()). TextGeometry - это пользовательское представление, родительский элемент которого имеет тот же размер, что и текстовое представление. Это то, что делает .background(). Очень мощный.
  2. В моей реализации TextGeometry я использую GeometryReader, чтобы получить геометрию родителя, что означает, что я получил геометрию представления Text, что означает, что теперь у меня есть ширина.
  3. Теперь, чтобы передать ширину, я использую Настройки. Там нет документации о них, но после небольшого эксперимента, я думаю, предпочтения - это что-то вроде "атрибутов вида", если хотите. Я создал свой собственный PreferenceKey, который называется WidthPreferenceKey, и я использую его в TextGeometry для "прикрепления" ширины к представлению, чтобы его можно было прочитать выше в иерархии.
  4. Вернувшись к предку, я использую onPreferenceChange для обнаружения изменений в ширине и соответствующим образом устанавливаю массив widths.

Все это может показаться слишком сложным, но код иллюстрирует это лучше всего. Вот новая реализация:

import SwiftUI

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> Length {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}

struct WidthPreferenceKey: PreferenceKey {
    static var defaultValue = CGFloat(0)

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }

    typealias Value = CGFloat
}


struct GridViewHeader : View {

    @State private var activeIdx: Int = 0
    @State private var w: [Length] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 0))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[0] = $0 })

                Spacer()

                Text("Tweets & Replies")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 1))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[1] = $0 })

                Spacer()

                Text("Media")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 2))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[2] = $0 })

                Spacer()

                Text("Likes")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 3))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[3] = $0 })

                }
                .frame(height: 50)
                .padding(.horizontal, 10)
            Rectangle()
                .alignmentGuide(.underlineLeading) { d in d[.leading]  }
                .frame(width: w[activeIdx],  height: 2)
                .animation(.basic())
        }
    }
}

struct TextGeometry: View {
    var body: some View {
        GeometryReader { geometry in
            return Rectangle().preference(key: WidthPreferenceKey.self, value: geometry.size.width)
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    let idx: Int

    func body(content: Content) -> some View {
        Group {
            if activeIdx == idx {
                content.alignmentGuide(.underlineLeading) { d in
                    return d[.leading]
                }.tapAction { self.activeIdx = self.idx }

            } else {
                content.tapAction { self.activeIdx = self.idx }
            }
        }
    }
}

Ответ 2

Дайте это попробовать:

import SwiftUI

var titles = ["Tweets", "Tweets & Replies", "Media", "Likes"]

struct GridViewHeader : View {

    @State var selectedItem: String = "Tweets"

    var body: some View {
        HStack(spacing: 20) {
            ForEach(titles.identified(by: \.self)) { title in
                HeaderTabButton(title: title, selectedItem: self.$selectedItem)
                }
                .frame(height: 50)
        }.padding(.horizontal, 10)

    }
}

struct HeaderTabButton : View {
    var title: String

    @Binding var selectedItem: String

    var isSelected: Bool {
        selectedItem == title
    }

    var body: some View {
        VStack {
            Button(action: { self.selectedItem = self.title }) {
                Text(title).fixedSize(horizontal: true, vertical: false)

                Rectangle()
                    .frame(height: 2, alignment: .bottom)
                    .relativeWidth(1)
                    .foregroundColor(isSelected ? Color.accentColor : Color.clear)

            }
        }
    }
}

А вот как это выглядит в превью: Preview screen

Ответ 3

Здесь очень простое решение, хотя оно не учитывает растяжение вкладок на всю ширину, но это должно быть лишь незначительной дополнительной математикой для расчета заполнения.

import SwiftUI

struct HorizontalTabs: View {

  private let tabsSpacing = CGFloat(16)

  private func tabWidth(at index: Int) -> CGFloat {
    let label = UILabel()
    label.text = tabs[index]
    let labelWidth = label.intrinsicContentSize.width
    return labelWidth
  }

  private var leadingPadding: CGFloat {
    var padding: CGFloat = 0
    for i in 0..<tabs.count {
      if i < selectedIndex {
        padding += tabWidth(at: i) + tabsSpacing
      }
    }
    return padding
  }

  let tabs: [String]

  @State var selectedIndex: Int = 0

  var body: some View {
    VStack(alignment: .leading) {
      HStack(spacing: tabsSpacing) {
        ForEach(0..<tabs.count, id: \.self) { index in
          Button(action: { self.selectedIndex = index }) {
            Text(self.tabs[index])
          }
        }
      }
      Rectangle()
        .frame(width: tabWidth(at: selectedIndex), height: 3, alignment: .bottomLeading)
        .foregroundColor(.blue)
        .padding(.leading, leadingPadding)
        .animation(Animation.spring())
    }
  }
}

HorizontalTabs(tabs: ["one", "two", "three"]) рендерит это:

screenshot

Ответ 4

Вам просто нужно указать рамку с высотой внутри. Вот пример:

VStack {
    Text("First Text Label")

    Spacer().frame(height: 50)    // This line

    Text("Second Text Label")
}

Ответ 5

Это решение очень хорошо.

Но теперь это стало ошибкой компиляции, она исправлена. (Xcode11.1)

Это целый код.

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}

struct WidthPreferenceKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat(0)
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}


struct HorizontalTabsView : View {

    @State private var activeIdx: Int = 0
    @State private var w: [CGFloat] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 0))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[0] = $0 })

                Spacer()

                Text("Tweets & Replies")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 1))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[1] = $0 })

                Spacer()

                Text("Media")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 2))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[2] = $0 })

                Spacer()

                Text("Likes")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 3))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[3] = $0 })

                }
                .frame(height: 50)
                .padding(.horizontal, 10)

            Rectangle()
                .alignmentGuide(.underlineLeading) { d in d[.leading]  }
                .frame(width: w[activeIdx],  height: 2)
                .animation(.default)
        }
    }
}

struct TextGeometry: View {
    var body: some View {
        GeometryReader { geometry in
            return Rectangle()
                .foregroundColor(.clear)
                .preference(key: WidthPreferenceKey.self, value: geometry.size.width)
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    let idx: Int

    func body(content: Content) -> some View {
        Group {
            if activeIdx == idx {
                content.alignmentGuide(.underlineLeading) { d in
                    return d[.leading]
                }.onTapGesture { self.activeIdx = self.idx }

            } else {
                content.onTapGesture { self.activeIdx = self.idx }
            }
        }
    }
}

struct HorizontalTabsView_Previews: PreviewProvider {
    static var previews: some View {
        HorizontalTabsView()
    }
}