I’m attempting to implement a collapsible header in SwiftUI that collapses when the person scrolls down and expands when the person scrolls up.
I adopted this Medium publish: Collapsible Header in SwiftUI
The implementation works fantastic when scrolling usually, however I’ve run into an issue:
- When the person drags the view down from the highest (pulls to refresh model) after which releases, the header disappears/hides unexpectedly.
Right here’s a brief display screen recording displaying the problem:
Display Recording
And right here is the code I’m utilizing:
public struct CollapsibleHeaderList: View {
// MARK: - Personal Vars
personal let gadgets = Array(0..<100)
@State personal var currentHeight: Double = 0
@State personal var lastOffset: Double = 0
@State personal var animationDuration: TimeInterval = 0.2
@State personal var lastAnimationDate: Date?
personal 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)
Listing {
ForEach(gadgets, id: .self) { merchandise in
Textual content("Merchandise (merchandise)")
.onAppear {
viewModel.onCellAppear(index: merchandise)
}
}
}
}.surroundings(.defaultMinListRowHeight, 0)
.listStyle(.plain)
.onReceive(viewModel.$state) { state in
DispatchQueue.major.async {
swap(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 {
@Revealed personal(set) public var state: HeaderState
personal var indexSubject = PassthroughSubject()
personal var cancellables = Set()
init() {
self.state = .preliminary
setupCollapsibleHeaderListener()
}
public func onCellAppear(index: Int) {
indexSubject.ship(index)
}
personal func setupCollapsibleHeaderListener() {
indexSubject
.throttle(for: .seconds(0.5), scheduler: DispatchQueue.major, 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 {
/// Contains the present component in addition to the earlier component from the upstream writer in a tuple the place the earlier component is non-compulsory.
/// The primary time the upstream writer emits a component, the earlier component might 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) (Non-obligatory(1), 2) (Non-obligatory(2), 3) (Non-obligatory(3), 4) (Non-obligatory(4), 5) ".
///
/// - Returns: A writer of a tuple of the earlier and present components from the upstream writer.
public func withPrevious() -> AnyPublisher<(earlier: Output?, present: Output), Failure> {
scan(Non-obligatory<(Output?, Output)>.none) { ($0?.1, $1) }
.compactMap { $0 }
.eraseToAnyPublisher()
}
}
Query:
How can I forestall the header from hiding when the person pulls down from the highest?
Replace:
I attempted to implement the answer @Benzy Neez
instructed, 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 will not be seen — it disappears instantly.
Right here is the display screen recording : Display recording 2
Right here is myCurrent code:
@State personal var showingHeader = true
var physique: some View {
ZStack {
Colour.backgroundColor3
.edgesIgnoringSafeArea(.all)
VStack(spacing: 0) {
if showingHeader {
GlobalContactsFilterView(viewModel: viewModel)
.body(peak: 60)
.transition(
.uneven(
insertion: .push(from: .prime),
removing: .push(from: .backside)
)
)
}
if viewModel.isRefreshing {
VStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
.body(maxWidth: .infinity)
.padding(.prime, 10)
}
if viewModel.contacts.isEmpty && !viewModel.isLoading && !viewModel.isRefreshing {
NoDataView()
} else {
GeometryReader { outer in
let outerHeight = outer.measurement.peak
ScrollView(.vertical) {
LazyVStack {
ForEach(Array(viewModel.contacts.enumerated()), id: .component.id) { index, contact in
VStack(alignment: .main) {
VStack {
VStack(spacing: 12) {
if contactVisibleFields.accommodates(.firstName) {
ContactDetailField(title: Strings.firstName, textual content: contact.firstName, textFont: .system(measurement: 13, weight: .semibold))
}
Rectangle()
.fill(.clear)
.body(maxWidth: .infinity, maxHeight: 1)
}
.padding(.prime, 10)
}
.body(maxWidth: .infinity)
.background(.backgroundColor2)
.padding(.backside, 3)
}
.body(maxWidth: .infinity)
.background(index == viewModel.contacts.rely - 1 ? Colour.clear : Colour.filterBarBackground)
.padding(.backside, index == viewModel.contacts.rely - 1 ? 50 : 0)
}
.listRowBackground(Colour.clear)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
HStack {
Spacer()
if viewModel.isLoading && !viewModel.isRefreshing {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
} else {
Colour.clear
.body(peak: 1)
.onAppear {
if let _ = clerk.person {
viewModel.fetchContacts()
}
}
}
Spacer()
}
.body(maxWidth: .infinity)
.listRowSeparator(.hidden)
.listRowBackground(Colour.clear)
}
.background {
GeometryReader { proxy in
let contentHeight = proxy.measurement.peak
let minY = max(
min(0, proxy.body(in: .named("ScrollView")).minY),
outerHeight - contentHeight
)
Colour.clear
.onChange(of: minY) { oldVal, newVal in
if (showingHeader && newVal < oldVal) || !showingHeader && newVal > oldVal {
showingHeader = newVal > oldVal
}
}
}
}
}
.coordinateSpace(title: "ScrollView")
}
.padding(.prime, 1)
}
}
//.body(maxHeight: .infinity, alignment: .prime)
.animation(.easeInOut, worth: showingHeader)