完成イメージ
- 画面上部にでっかく画像を表示する(アルバムのアートワークとか)
- その画像の下の方にページタイトルを表示する(アルバム名とか)
- スクロールしていくと、だんだん画像が暗くなっていく
- 画像のスクロール量をページ自体のスクロール量よりも小さくすることで、画像が少し奥にあるような視差効果をつくる
- 画像の下部にあるページタイトルが画面から消えるぐらいのタイミングで、タイトルを画面上部に固定する
画像とタイトルとその下のListを作る
struct HogeView2: View {
var body: some View {
ScrollView {
VStack(spacing: 0) {
ZStack(alignment: .bottomLeading) {
Image("Wallpaper 1")
.resizable()
.frame(height: 400)
.frame(maxWidth: .infinity)
Text("Page Title")
.font(.largeTitle).bold()
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading)
.background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
}
.frame(width: .infinity)
VStack {
ForEach(0 ..< 30) { item in
RoundedRectangle(cornerRadius: 10)
.fill(.gray)
.frame(height: 100)
.padding(10)
}
}
.background(.white)
}
}
.ignoresSafeArea()
}
}
スクロール量を取得する
-
ScrollOffsetPreferenceKey
を作成 -
ScrollView
の一個内側の要素のbackground
にGeometryReader
を配置し、さらにその内側に透明なpreferenceを設定した要素を配置する -
onPreferenceChange
イベントでスクロール量を取得し、Stateを更新する - Yスクロール量を保持するStateを作成し、とりあえずTextで表示してみる
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
}
}
struct HogeView2: View {
@State var scrollOffset: CGFloat = 0
var body: some View {
ScrollView {
VStack(spacing: 0) {
ZStack(alignment: .bottomLeading) {
Image("Wallpaper 1")
.resizable()
.frame(height: 400)
.frame(maxWidth: .infinity)
Text("Page Title")
.font(.largeTitle).bold()
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading)
.background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
}
.frame(width: .infinity)
Text("\(scrollOffset)")
VStack {
ForEach(0 ..< 30) { item in
RoundedRectangle(cornerRadius: 10)
.fill(.gray)
.frame(height: 100)
.padding(10)
}
}
.background(.white)
}
.background(GeometryReader { geometry in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
})
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
self.scrollOffset = value.y
}
}
.ignoresSafeArea()
}
}
スクロール量に応じて画像を暗くする
-
imageOverlayOpacity
Stateを作成 - 画像の上に半透明のレイヤーを作成する(opacityに
imageOverlayOpacity
を指定する) -
onPreferenceChange
イベント内で、imageOverlayOpacity
の値を変化させる - 画像の高さは400なので、スクロール量が0の時opacityが0.0、スクロール量が400のときにopacityが1.0になるようにする
struct HogeView2: View {
@State var imageOverlayOpacity: CGFloat = 0
var body: some View {
ScrollView {
VStack(spacing: 0) {
ZStack(alignment: .bottomLeading) {
Image("Wallpaper 1")
.resizable()
.frame(height: 400)
.frame(maxWidth: .infinity)
Text("Page Title")
.font(.largeTitle).bold()
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading)
.background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
Rectangle()
.fill(.black.opacity(imageOverlayOpacity))
}
.frame(width: .infinity)
VStack {
ForEach(0 ..< 30) { item in
RoundedRectangle(cornerRadius: 10)
.fill(.gray)
.frame(height: 100)
.padding(10)
}
}
.background(.white)
}
.background(GeometryReader { geometry in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
})
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
self.imageOverlayOpacity = -value.y / 400
}
}
.ignoresSafeArea()
}
}
スクロール量に応じて画像をオフセットさせる
たとえば、100ポイントスクロールされたときに画像が50ポイント下に相対的にオフセットするようにすると、画像以外の要素は100ポイント上に進んだのに対して、画像は上に50ポイントしか進んでないというような移動量の差が作れる。
この移動量の差が視差効果になって、画像が少し奥にあるような立体的なインタラクションを実現する
struct HogeView2: View {
@State var imageOverlayOpacity: CGFloat = 0
@State var imageOffset: CGFloat = 0
var body: some View {
ScrollView {
VStack(spacing: 0) {
ZStack(alignment: .bottomLeading) {
Image("Wallpaper 1")
.resizable()
.frame(height: 400)
.frame(maxWidth: .infinity)
.offset(y: imageOffset)
Text("Page Title")
.font(.largeTitle).bold()
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading)
.background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
Rectangle()
.fill(.black.opacity(imageOverlayOpacity))
}
.frame(width: .infinity)
VStack {
ForEach(0 ..< 30) { item in
RoundedRectangle(cornerRadius: 10)
.fill(.gray)
.frame(height: 100)
.padding(10)
}
}
.background(.white)
}
.background(GeometryReader { geometry in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
})
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
self.imageOverlayOpacity = -value.y / 400
self.imageOffset = -value.y / 5.0
}
}
.ignoresSafeArea()
}
}
一定のスクロール量を超えたタイミングでナビゲーションバーを出現させる
-
NavigationStack
の内側にGeometryReader
を配置することでsafeArea
を取得することができる -
safeArea.top
の値を使ってヘッダーっぽいRectangleを描画して、ZStackで上部に重ねて表示することで、お手製でヘッダーを再現できる。
struct HogeView2: View {
@State var imageOverlayOpacity: CGFloat = 0
@State var imageOffset: CGFloat = 0
var body: some View {
NavigationStack {
GeometryReader { geometory in
ZStack(alignment: .top) {
content
header(geometry: geometory)
}
.ignoresSafeArea()
}
}
}
func header(geometry: GeometryProxy) -> some View {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
.frame(height: geometry.safeAreaInsets.top + 40)
VStack {
Spacer().frame(height: geometry.safeAreaInsets.top)
Text("Page Title")
.font(.title2)
.foregroundColor(.primary)
}
}
}
var content: some View {
ScrollView {
VStack(spacing: 0) {
ZStack(alignment: .bottomLeading) {
Image("Wallpaper 1")
.resizable()
.frame(height: 400)
.frame(maxWidth: .infinity)
.offset(y: imageOffset)
Text("Page Title")
.font(.largeTitle).bold()
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading)
.background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
Rectangle()
.fill(.black.opacity(imageOverlayOpacity))
}
.frame(width: .infinity)
VStack {
ForEach(0 ..< 30) { item in
RoundedRectangle(cornerRadius: 10)
.fill(.gray)
.frame(height: 100)
.padding(10)
}
}
.background(.white)
}
.background(GeometryReader { geometry in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
})
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
self.imageOverlayOpacity = -value.y / 400
self.imageOffset = -value.y / 5.0
}
}
}
}
- あとは初期状態でナビゲーションバーのopacityを0にすることで見えなくしておいて、
画像の高さ-ナビゲーションバーの高さ
の分がスクロールされた時にopacityを1にして出現させればOK
struct HogeView2: View {
@State var imageOverlayOpacity: CGFloat = 0
@State var imageOffset: CGFloat = 0
@State var navigationBarOpacity: CGFloat = 0
var body: some View {
NavigationStack {
GeometryReader { geometory in
ZStack(alignment: .top) {
content(navigationBarHeight: geometory.safeAreaInsets.top + 40)
navigationBar(height: geometory.safeAreaInsets.top + 40)
}
.ignoresSafeArea()
}
}
}
func navigationBar(height: CGFloat) -> some View {
ZStack {
Rectangle()
.fill(.ultraThinMaterial.opacity(navigationBarOpacity))
.frame(height: height)
VStack {
Spacer().frame(height: height - 40)
Text("Page Title")
.font(.title2)
.foregroundColor(.primary.opacity(navigationBarOpacity))
}
}
}
func content(navigationBarHeight: CGFloat) -> some View {
ScrollView {
VStack(spacing: 0) {
ZStack(alignment: .bottomLeading) {
Image("Wallpaper 1")
.resizable()
.frame(height: 400)
.frame(maxWidth: .infinity)
.offset(y: imageOffset)
Text("Page Title")
.font(.largeTitle).bold()
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading)
.background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
Rectangle()
.fill(.black.opacity(imageOverlayOpacity))
}
.frame(width: .infinity)
VStack {
ForEach(0 ..< 30) { item in
RoundedRectangle(cornerRadius: 10)
.fill(.gray)
.frame(height: 100)
.padding(10)
}
}
.background(.white)
}
.background(GeometryReader { geometry in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
})
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
self.imageOverlayOpacity = -value.y / 400
self.imageOffset = -value.y / 5.0
self.navigationBarOpacity = CGFloat(Int(-value.y / (400 - navigationBarHeight)))
}
}
}
}