Animated Auto Scrollable Header Menu

Updated:

Animated Auto Scrollable Header Menu

스크린샷

Source Code - https://github.com/jacobkosmart/Animated-Auto-Scrollable-Header-Menu-Practice

상단 카테고리 영역을 클릭하면 자동으로 스크롤 되서 리스트가 업데이트 되며, 스크롤 할 경우에도 자동으로 상단 카테고리 영역도 업데이트 되는 기능 구현

🔶 DB

Data 와 Asset 은 최근 프로젝트인 멸종위기동물사전 APP 에서 가져와서 사용하였음

endangered-animals-kr-app - https://github.com/jacobkosmart/endangered-animals-kr-app

  • type 과 body 를 추가해서 type1 ~ type 5 단계 별로 json 파일을 변형 함 -> animalType.json 파일 참조

🔶 Decode json

// in CodableBundleExtension.swift
import Foundation

extension Bundle {
	func decode<T: Codable>(_ file: String) -> T {
		// 1.Locate the json file
		guard let url = self.url(forResource: file, withExtension: nil) else {
			fatalError("Failed to locate \(file) in bundle")
		}
		// 2.Create a property for the data
		guard let data = try? Data(contentsOf: url) else {
			fatalError("Failed to load \(file) from bundle")
		}
		// 3.Create a decoder
		let decoder = JSONDecoder()

		// 4.Create a property for the decoded data
		guard let loaded = try? decoder.decode(T.self, from: data) else {
			fatalError("Failed to decode \(file) from bundle.")
		}

		// 5.Return the ready-to-use data
		print(loaded)
		return loaded
	}
}

🔶 Model

//  AnimalModel.swift

import Foundation
import SwiftUI

// Sample Tabs with sample animals

struct AnimalType: Codable{
	var type : String
	var body: [Animal]
}

struct Animal: Codable, Identifiable {
	var id : String
	let name: String
	let headline: String
	let description: String
	let link: String
	let image: String
	let gallery: [String]
	let fact: [String]
	let copyright: String
}

// Tab Model ..
struct Tab: Identifiable {
	var id = UUID().uuidString
	var tab: String
	var animals: [Animal]
}

🔶 ViewModel

//  AnimalViewModel.swift

import SwiftUI

class AnimalViewModel: ObservableObject {
	// MARK: -  PROPERTY
	@Published var animalTypes: [AnimalType]
	@Published var tabItems: [Tab]
	@Published var currentTab:String = ""


	init() {
		let fetchData: [AnimalType] = Bundle.main.decode("animaltype.json")
		self.animalTypes = fetchData
		self.tabItems = [
			Tab(tab: "Type1", animals: fetchData[0].body),
			Tab(tab: "Type2", animals: fetchData[1].body),
			Tab(tab: "Type3", animals: fetchData[2].body),
			Tab(tab: "Type4", animals: fetchData[3].body),
			Tab(tab: "Type5", animals: fetchData[4].body)
		]
	}
}

🔶 ContentView

//  ContentView.swift

import SwiftUI

struct ContentView: View {
// MARK: -  PROPERTY

@EnvironmentObject private var vm: AnimalViewModel
@Namespace var animation
@Environment(\.colorScheme) var scheme

// MARK: -  BODY
var body: some View {
ZStack {
  // Background

  // foreground
  VStack (spacing: 0){
    VStack {
      header
      categoryView
        .padding([.top])
      bodyView

    } //: VSTACK
  } //: VSTACK
  .padding([.horizontal, .top])
} //: ZSTACK
.background(.ultraThinMaterial)
}

}

// MARK: -  PREVIEW
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
  ContentView()
    .environmentObject(AnimalViewModel())
}
}

// MARK: -  EXTENSTION
extension ContentView {

// Header
private var header: some View {

HStack (spacing: 15) {

Button {

} label: {
Image(systemName: "list.dash").font(.title2)
}

Text("Animal List")
.font(.title3)
.fontWeight(.semibold)
.hLeading()

Button {

} label: {
Image(systemName: "magnifyingglass")
.font(.title2)
}

} //: HSTACK
.foregroundColor(.primary)
.padding(.horizontal)

}


// Category View
private var categoryView: some View {
// Scroll View Reader..
// to scroll tab automatically when user scrolls..
ScrollViewReader { proxy in

ScrollView(.horizontal, showsIndicators: false) {
HStack (spacing: 30) {

ForEach(vm.tabItems) { tab in
VStack {

  Text(tab.tab)
    .foregroundColor(vm.currentTab.replacingOccurrences(of: " SCROLL", with: "") == tab.id ? .primary : .gray)

  // For matched geometry effect..
  if vm.currentTab.replacingOccurrences(of: " SCROLL", with: "") == tab.id {
    Capsule()
      .fill(Color.accentColor)
      .matchedGeometryEffect(id: "TAB", in: animation)
      .frame(height: 3)
      .padding(.horizontal, -10)
  } else {
    Capsule()
      .fill(.clear)
      .frame(height: 3)
      .padding(.horizontal, -10)
  }

} //: VSTACK
.onTapGesture {
  withAnimation(.easeInOut) {
    vm.currentTab = "\(tab.id) TAP"
    proxy.scrollTo(vm.currentTab.replacingOccurrences(of: " TAP", with: ""), anchor: .topTrailing)
  }
}
} //: LOOP
} //: HSTACK
.padding(.horizontal, 30)
.background(.ultraThinMaterial)
} //: SCROLL
.onChange(of: vm.currentTab, perform: { _ in
// Enabling scrolling..
if vm.currentTab.contains(" SCROLL") {
withAnimation(.easeInOut) {
proxy.scrollTo(vm.currentTab.replacingOccurrences(of: " SCROLL", with: ""), anchor: .topTrailing)
}
}
})
// Divider
.background(scheme == .dark ? Color.black : Color.white)
.overlay(
Divider()
.padding(.horizontal, -15)
, alignment: .bottom
)
} //: SCROLLREADER
// Setting first tab..
.onAppear {
vm.currentTab = vm.tabItems.first?.id ?? ""
}
}

// Body View
private var bodyView: some View {
// Scrool view reader to scroll the content..
ScrollView(.vertical, showsIndicators: false) {
ScrollViewReader { proxy in
VStack (spacing: 15) {
ForEach(vm.tabItems) { tab in
  // Animal Card Item
AnimalCardView(tab: tab, currentTab: $vm.currentTab)
}
} //: VSTACK
.padding([.bottom])
.onChange(of: vm.currentTab) { newValue in
// avoid scroll if its tap..
if vm.currentTab.contains(" TAP") {
// Scrolling to content..
withAnimation(.easeInOut) {
  proxy.scrollTo(vm.currentTab.replacingOccurrences(of: " TAP", with: ""), anchor: .topTrailing)
}
}
}
} //: SCROLLREADER
} //: SCROLL
// Setting Coordinate Space name for offset..
.coordinateSpace(name: "SCROLL")
}
}


struct AnimalCardView: View {

let tab: Tab
@Binding var currentTab: String

var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(tab.tab)
.font(.title.bold())
.padding(.vertical)

ForEach(tab.animals) { animal in
HStack(alignment: .center, spacing: 16) {
Image(animal.image)
.resizable()
.scaledToFill()
.frame(width: 90, height: 90)
.clipShape(
  RoundedRectangle(cornerRadius: 12)
)

VStack(alignment: .leading, spacing: 8) {
Text(animal.name)
  .font(.title2)
  .foregroundColor(.accentColor)

Text(animal.headline)
  .font(.footnote)
  .multilineTextAlignment(.leading)
  .lineLimit(2)
  .padding(.trailing, 8)
} //: VSTACK
} //: HSTACK
Divider()
} //: LOOP
} //: VSTACK
.modifier(OffsetModifier(tab: tab, currentTab: $currentTab))
.id(tab.id)
}
}

🔶 OffsetModifier

//  OffsetModifier.swift

import SwiftUI

struct OffsetModifier: ViewModifier {

var tab: Tab
@Binding var currentTab: String

func body(content: Content) -> some View {
content
.overlay(
// Getting Scroll Offset using Geometry Reader..
GeometryReader { proxy in
  Color.clear
    .preference(key: OffsetKey.self, value: proxy.frame(in: .named("SCROLL")))
}
)
.onPreferenceChange(OffsetKey.self) { proxy in
print(proxy.minY)

// if minY is between 20 to -half of the midX
// then updating current tab..

// Since on chnage on Content is updating Scroll..
// to avoid that..

// Adding "SCROLL" to last of ID..
// To identify Easily..

let offset = proxy.minY
withAnimation(.easeInOut) {
  currentTab = (offset < 20  && -offset < (proxy.midX / 2) && currentTab != tab.id) ? "\(tab.id) SCROLL" : currentTab
}
}
}
}

struct OffsetModifier_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}


// Preference Key..
struct OffsetKey: PreferenceKey {

static var defaultValue: CGRect = .zero

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

🔶 CustomAlignment

import SwiftUI

extension View {

	// MARK: -  Vertical Center
	func vCenter() -> some View {
		self.frame(maxHeight: .infinity, alignment: .center)
	}

	// MARK: -  Vertical Top
	func vTop() -> some View {
		self.frame(maxHeight: .infinity, alignment: .top)
	}

	// MARK: -  Vertical Bottom
	func vBottom() -> some View {
		self.frame(maxHeight: .infinity, alignment: .bottom)
	}
	// MARK: -  Horizontal Center

	func hCenter() -> some View {
		self.frame(maxWidth: .infinity, alignment: .center)
	}

	// MARK: -  Horizontal Leading
	func hLeading() -> some View {
		self.frame(maxWidth: .infinity, alignment: .leading)
	}

	// MARK: -  Horizontal Trailing
	func hTrailing() -> some View {
		self.frame(maxWidth: .infinity, alignment: .trailing)
	}
}




🗃 Reference

Kavsoft - https://www.youtube.com/watch?v=wQ6JYXNVpY0&t=13s

Categories:

Updated:

Leave a comment