I’m attempting to implement a collapsible header in SwiftUI that collapses when the consumer scrolls down and expands when the consumer scrolls up.
I adopted this Medium put up: Collapsible Header in SwiftUI
The implementation works tremendous when scrolling usually, however I’ve run into an issue:
- When the consumer drags the view down from the highest (pulls to refresh fashion) after which releases, the header disappears/hides unexpectedly.
Right here’s a brief display recording exhibiting the problem:
Display Recording
And right here is the code I’m utilizing:
public struct CollapsibleHeaderList: View {
// MARK: - Non-public Vars
non-public let gadgets = Array(0..<100)
@State non-public var currentHeight: Double = 0
@State non-public var lastOffset: Double = 0
@State non-public var animationDuration: TimeInterval = 0.2
@State non-public var lastAnimationDate: Date?
non-public var expandedHeight: CGFloat = 40.0
@ObservedObject var viewModel: CollapsibleHeaderViewModel
public init(viewModel: CollapsibleHeaderViewModel) {
self.viewModel = viewModel
}
// MARK: - View
public var physique: some View {
VStack {
Rectangle()
.foregroundColor(.pink)
.body(peak: currentHeight)
Checklist {
ForEach(gadgets, id: .self) { merchandise in
Textual content("Merchandise (merchandise)")
.onAppear {
viewModel.onCellAppear(index: merchandise)
}
}
}
}.atmosphere(.defaultMinListRowHeight, 0)
.listStyle(.plain)
.onReceive(viewModel.$state) { state in
DispatchQueue.principal.async {
change(state) {
case .collapse:
collapseHeader()
case .develop:
expandHeader()
case .preliminary:
break
}
}
}
}
func expandHeader() {
withAnimation(.easeOut(period: animationDuration)) {
currentHeight = expandedHeight
lastAnimationDate = Date()
}
}
func collapseHeader() {
withAnimation(.easeOut(period: animationDuration)) {
currentHeight = 0
lastAnimationDate = Date()
}
}
func didFinishLastAnimation() -> Bool {
guard let lastAnimationDate else {
return true
}
return abs(lastAnimationDate.timeIntervalSinceNow) > animationDuration
}
}
public enum HeaderState {
case preliminary
case collapse
case develop
}
public class CollapsibleHeaderViewModel: ObservableObject {
@Printed non-public(set) public var state: HeaderState
non-public var indexSubject = PassthroughSubject()
non-public var cancellables = Set()
init() {
self.state = .preliminary
setupCollapsibleHeaderListener()
}
public func onCellAppear(index: Int) {
indexSubject.ship(index)
}
non-public func setupCollapsibleHeaderListener() {
indexSubject
.throttle(for: .seconds(0.5), scheduler: DispatchQueue.principal, newest: true)
.withPrevious()
.map { (earlier, present) in
if let earlier, earlier < present {
return .collapse
} else {
return .develop
}
}
.removeDuplicates()
.sink { [weak self] headerState in
self?.state = headerState
}.retailer(in: &cancellables)
}
}
extension Writer {
/// Consists of the present component in addition to the earlier component from the upstream writer in a tuple the place the earlier component is elective.
/// The primary time the upstream writer emits a component, the earlier component will likely be `nil`.
/// This code was copied from https://stackoverflow.com/questions/63926305/combine-previous-value-using-combine
///
/// let vary = (1...5)
/// cancellable = vary.writer
/// .withPrevious()
/// .sink { print ("(($0.earlier), ($0.present))", terminator: " ") }
/// // Prints: "(nil, 1) (Elective(1), 2) (Elective(2), 3) (Elective(3), 4) (Elective(4), 5) ".
///
/// - Returns: A writer of a tuple of the earlier and present parts from the upstream writer.
public func withPrevious() -> AnyPublisher<(earlier: Output?, present: Output), Failure> {
scan(Elective<(Output?, Output)>.none) { ($0?.1, $1) }
.compactMap { $0 }
.eraseToAnyPublisher()
}
}
Query:
How can I forestall the header from hiding when the consumer pulls down from the highest?
Replace:
I attempted to implement the answer @Benzy Neez
advised, however now I get a bizarre shaking/jitter impact when the header seems. Additionally, when the view seems for the very first time, the header just isn’t seen — it disappears instantly.
Right here is the display recording : Display recording 2
Right here is myCurrent code:
@State non-public var showingHeader = true
var physique: some View {
ZStack {
Coloration.backgroundColor3
.edgesIgnoringSafeArea(.all)
VStack(spacing: 0) {
if showingHeader {
GlobalContactsFilterView(viewModel: viewModel)
.body(peak: 60)
.transition(
.uneven(
insertion: .push(from: .high),
removing: .push(from: .backside)
)
)
}
if viewModel.isRefreshing {
VStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
.body(maxWidth: .infinity)
.padding(.high, 10)
}
if viewModel.contacts.isEmpty && !viewModel.isLoading && !viewModel.isRefreshing {
NoDataView()
} else {
GeometryReader { outer in
let outerHeight = outer.dimension.peak
ScrollView(.vertical) {
LazyVStack {
ForEach(Array(viewModel.contacts.enumerated()), id: .component.id) { index, contact in
VStack(alignment: .main) {
VStack {
VStack(spacing: 12) {
if contactVisibleFields.incorporates(.firstName) {
ContactDetailField(title: Strings.firstName, textual content: contact.firstName, textFont: .system(dimension: 13, weight: .semibold))
}
Rectangle()
.fill(.clear)
.body(maxWidth: .infinity, maxHeight: 1)
}
.padding(.high, 10)
}
.body(maxWidth: .infinity)
.background(.backgroundColor2)
.padding(.backside, 3)
}
.body(maxWidth: .infinity)
.background(index == viewModel.contacts.depend - 1 ? Coloration.clear : Coloration.filterBarBackground)
.padding(.backside, index == viewModel.contacts.depend - 1 ? 50 : 0)
}
.listRowBackground(Coloration.clear)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
HStack {
Spacer()
if viewModel.isLoading && !viewModel.isRefreshing {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
} else {
Coloration.clear
.body(peak: 1)
.onAppear {
if let _ = clerk.consumer {
viewModel.fetchContacts()
}
}
}
Spacer()
}
.body(maxWidth: .infinity)
.listRowSeparator(.hidden)
.listRowBackground(Coloration.clear)
}
.background {
GeometryReader { proxy in
let contentHeight = proxy.dimension.peak
let minY = max(
min(0, proxy.body(in: .named("ScrollView")).minY),
outerHeight - contentHeight
)
Coloration.clear
.onChange(of: minY) { oldVal, newVal in
if (showingHeader && newVal < oldVal) || !showingHeader && newVal > oldVal {
showingHeader = newVal > oldVal
}
}
}
}
}
.coordinateSpace(identify: "ScrollView")
}
.padding(.high, 1)
}
}
//.body(maxHeight: .infinity, alignment: .high)
.animation(.easeInOut, worth: showingHeader)