本記事では、SwiftUIアプリケーション内で、ユーザーアバターを含むカスタムタブバーと、スワイプ操作で開くサイドバーを実装する手順を紹介します。
ツイッター風 ?-> X風 ww
カスタムタブバー
カスタムタブバーを利用することで、画像を載せたり、メニューを拡張したり(例えば、素早く別アカウントへ切り替えるといった機能を提供)することが容易になります。
まず、アプリで扱うタブをenumで定義します。
例:home, settings, profileといったタブを用意します。
enum CurrentTab {
case home
case settings
case profile
}
選択中のタブを管理するために @State
変数を用意し、
TabView
とバインドします。
TabView内には各ページ(Text("Home page")など)を.tag()で紐づけ、現在のタブを識別できるようにします。
@State private var currentTab: CurrentTab = .home
通常、TabView内の各ビューに.tabItemを指定すればシステム標準のタブバーが表示されますが、ここでは.tabItemを与えず、カスタムタブバーを上から重ねる方法を取ります。
TabView(selection: $currentTab) {
Text("Home page")
.tag(CurrentTab.home)
Text("Settings page")
.tag(CurrentTab.settings)
Text("Profile page")
.tag(CurrentTab.profile)
}
ZStackでTabViewを下層に置き、ZStack(alignment: .bottom)で最下部にカスタムタブバーを重ねるようなレイアウトを構築します。
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $currentTab) {
Text("Home page")
.tag(CurrentTab.home)
Text("Settings page")
.tag(CurrentTab.settings)
Text("Profile page")
.tag(CurrentTab.profile)
}
floatingToolBar
}
}
カスタムタブバーを構築するために、まず1つのタブアイテムを描画するための関数を用意します。ここではImage(systemName:)を用いてアイコンを表示し、選択中の場合は色や不透明度を変える実装例を示します。
func CustomTabItem(symbolName: String, isActive: Bool) -> some View{
HStack {
Image(systemName: symbolName)
.resizable()
.foregroundColor(isActive ? .teal : .gray)
.opacity(isActive ? 1 : 0.6)
.frame(width: 22, height: 22)
}
.frame(maxWidth: .infinity)
.frame(height: 38)
}
次に、HStackを用いて複数のタブボタンを並べ、その中にはButtonを利用してcurrentTabを切り替えます。このとき、ユーザーアバター部分にはAsyncImageを使うことで、URLから画像を非同期ロードします。
var floatingToolBar: some View {
HStack {
Spacer()
Button {
self.currentTab = .home
} label: {
CustomTabItem(
symbolName: "house",
isActive: self.currentTab == .home)
}
Spacer()
Button {
self.currentTab = .settings
} label: {
CustomTabItem(
symbolName: "gear",
isActive: self.currentTab == .settings)
}
Spacer()
Button {
self.currentTab = .profile
} label: {
AsyncImage(url: URL(string: "https://cdn.pixabay.com/photo/2024/03/07/10/38/simba-8618301_640.jpg")!) { loadedImage in
loadedImage
.resizable()
.scaledToFit()
.clipShape(Circle())
.frame(maxWidth: .infinity)
.frame(height: 30)
.padding(2)
.background {
if self.currentTab == .profile {
Circle()
.stroke(.teal, lineWidth: 1)
}
}
} placeholder: {
ProgressView()
}
.frame(maxWidth: .infinity)
.frame(height: 30)
}
Spacer()
}
.frame(maxWidth: .infinity)
.padding(.top, 5)
.padding(.horizontal, 20)
.background(Color(uiColor: .systemGroupedBackground))
}
選択中のタブには境界線のサークルを描画するなど、自由なカスタマイズが可能です。
このように、標準のTabView+ZStack+HStackを組み合わせ、カスタムタブバーを自由に拡張できます。
ユーザーの好みに合わせて、表示するタブを増減したり、テキストラベルを表示するなど柔軟な拡張も簡単です。
カスタムサイドバー
続いて、左側からスワイプ操作でメニューを出し入れできるサイドバーを実装します。ツイッター風の「ドロワーメニュー」に近い体験を提供できます。
カスタムサイドバーを実装するために、HStackを使用してメインコンテンツの左側にメニューを配置します。
外側にGeometryReaderを使用して画面のサイズを読み取り、サイドバーの幅を計算します。
var body: some View {
GeometryReader { geometry in
let sideBarWidth = geometry.size.width - 100
}
}
次に、サイドバーの幅をsideBarWidth
として設定し、メインコンテンツビューの幅を画面の幅と同じに設定します。
var body: some View {
GeometryReader { geometry in
let sideBarWidth = geometry.size.width - 100
HStack(spacing: 0) {
// サイドメニュー
SideMenuView()
.frame(width: sideBarWidth)
// メインコンテンツ(上のセクションのTabViewです
MainContentView()
.frame(width: geometry.size.width)
}
}
}
しかし、そのようにフレームを設定すると、ビューが画面からはみ出してしまいます。そのため、ビューを最初に表示するときには、x軸方向にオフセットを設定して、最初はコンテンツのみが表示されるようにします。ユーザーがスクロールすることで初めてメニューが表示される仕組みです。
以下のコードでは、初期状態でnavigationState.offset
の値が0に設定されているため、メニューが隠れるように初期オフセットが設定されています。ユーザーが左から右にドラッグジェスチャーを開始すると、x軸のオフセットが更新され、サイドメニューの一部が表示されるようになります。
var body: some View {
GeometryReader { geometry in
let sideBarWidth = geometry.size.width - 100
HStack(spacing: 0) {
// サイドメニュー
SideMenuView()
.frame(width: sideBarWidth)
// メインコンテンツ(上のセクションのTabViewです
MainContentView()
.frame(width: geometry.size.width)
}
+ .offset(x: -sideBarWidth + navigationState.offset)
}
}
メインコンテンツビューにも.frame(width: geometry.size.width)
を定義することが重要です。これにより、画面全体の幅を埋めることができます。
その後、ユーザーのジェスチャーを処理するための追加コードを追加します。
以下が完成したコードです:
struct ContentView: View {
@StateObject private var navigationState = NavigationState()
@GestureState private var gestureOffset: CGFloat = 0
var body: some View {
GeometryReader { geometry in
let sideBarWidth = geometry.size.width - 100
HStack(spacing: 0) {
// サイドメニュー
SideMenuView()
.frame(width: sideBarWidth)
// メインコンテンツ(上のセクションのTabViewです
VStack {
HStack {
Rectangle().foregroundColor(.blue)
}
}
.frame(width: geometry.size.width)
}
.offset(x: -sideBarWidth + navigationState.offset)
.gesture(
DragGesture()
.updating($gestureOffset) { value, state, _ in // Use the local gestureOffset
state = value.translation.width
}
.onEnded { value in
navigationState.handleGestureEnd(value: value, sideBarWidth: sideBarWidth)
}
)
.animation(.linear(duration: 0.15), value: navigationState.offset == 0)
.onChange(of: navigationState.showMenu) { newValue in
handleMenuVisibilityChange(sideBarWidth: sideBarWidth)
}
.onChange(of: gestureOffset) { newValue in // Use the local gestureOffset
handleGestureOffsetChange(sideBarWidth: sideBarWidth, gestureOffset: newValue)
}
}
}
private func handleMenuVisibilityChange(sideBarWidth: CGFloat) {
if navigationState.showMenu && navigationState.offset == 0 {
navigationState.offset = sideBarWidth
navigationState.lastStoredOffset = navigationState.offset
}
if !navigationState.showMenu && navigationState.offset == sideBarWidth {
navigationState.offset = 0
navigationState.lastStoredOffset = 0
}
}
private func handleGestureOffsetChange(sideBarWidth: CGFloat, gestureOffset: CGFloat) {
if gestureOffset != 0 {
let potentialOffset = navigationState.lastStoredOffset + gestureOffset
if potentialOffset < sideBarWidth && potentialOffset > 0 {
navigationState.offset = potentialOffset
} else if potentialOffset < 0 {
navigationState.offset = 0
}
}
}
}
// MARK: - Navigation State
class NavigationState: ObservableObject {
@Published var showMenu: Bool = false
@Published var offset: CGFloat = 0
@Published var lastStoredOffset: CGFloat = 0
func handleGestureEnd(value: DragGesture.Value, sideBarWidth: CGFloat) {
withAnimation(.spring(duration: 0.15)) {
if value.translation.width > 0 {
// Handle opening gesture
if value.translation.width > sideBarWidth / 2 {
openMenu(sideBarWidth: sideBarWidth)
} else if value.velocity.width > 800 {
openMenu(sideBarWidth: sideBarWidth)
} else if !showMenu {
closeMenu()
}
} else {
// Handle closing gesture
if -value.translation.width > sideBarWidth / 2 {
closeMenu()
} else {
guard showMenu else { return }
if -value.velocity.width > 800 {
closeMenu()
} else {
openMenu(sideBarWidth: sideBarWidth)
}
}
}
}
lastStoredOffset = offset
}
private func openMenu(sideBarWidth: CGFloat) {
offset = sideBarWidth
lastStoredOffset = sideBarWidth
showMenu = true
}
private func closeMenu() {
offset = 0
showMenu = false
}
}
handleGestureOffsetChange
関数を使用して、オフセットが有効か(0からメニューの幅までの範囲内か)を確認します。有効であれば、その値をnavigationState.offset
に更新します。これにより、上記で設定したビューのオフセットに反映されます。
また、handleGestureEnd
関数では、複数の条件をチェックして、ユーザーが左から右にスワイプした場合にメニューを開くかどうかを判断します。
-
if value.translation.width > sideBarWidth / 2
この条件は、ユーザーがメニューの幅の半分以上スワイプした場合、メニューを開くことを意味します。 -
else if value.velocity.width > 800
この条件は、ユーザーがまだ必要な幅までスワイプしていないが、非常に速くスワイプしている場合に、メニューを開くようにします。
メニューを閉じるロジックも同様で、ユーザーがスワイプした距離と速度を確認して実行します。
これで、引き出しのように開閉できるサイドバーメニューが完成しました。
以下がサイドメニューとカスタムツールバーの完全なコードです:
//
// ContentView.swift
// TabBarSideBarDemo
//
// Created by msz on 2024/12/09.
//
import SwiftUI
enum CurrentTab {
case home
case settings
case profile
}
struct ContentView: View {
@StateObject private var navigationState = NavigationState()
@GestureState private var gestureOffset: CGFloat = 0
@State private var currentTab: CurrentTab = .home
var body: some View {
GeometryReader { geometry in
let sideBarWidth = geometry.size.width - 100
HStack(spacing: 0) {
// Side Menu
SideMenuView()
.frame(width: sideBarWidth)
// Main Content, which is the TabView from the above section
ZStack(alignment: .bottom) {
TabView(selection: $currentTab) {
Text("Home page")
.tag(CurrentTab.home)
Text("Settings page")
.tag(CurrentTab.settings)
Text("Profile page")
.tag(CurrentTab.profile)
}
floatingToolBar
}
.frame(width: geometry.size.width)
}
.offset(x: -sideBarWidth + navigationState.offset)
.gesture(
DragGesture()
.updating($gestureOffset) { value, state, _ in // Use the local gestureOffset
state = value.translation.width
}
.onEnded { value in
navigationState.handleGestureEnd(value: value, sideBarWidth: sideBarWidth)
}
)
.animation(.linear(duration: 0.15), value: navigationState.offset == 0)
.onChange(of: navigationState.showMenu) { newValue in
handleMenuVisibilityChange(sideBarWidth: sideBarWidth)
}
.onChange(of: gestureOffset) { newValue in // Use the local gestureOffset
handleGestureOffsetChange(sideBarWidth: sideBarWidth, gestureOffset: newValue)
}
}
}
// MARK: Custom side menu
private func handleMenuVisibilityChange(sideBarWidth: CGFloat) {
if navigationState.showMenu && navigationState.offset == 0 {
navigationState.offset = sideBarWidth
navigationState.lastStoredOffset = navigationState.offset
}
if !navigationState.showMenu && navigationState.offset == sideBarWidth {
navigationState.offset = 0
navigationState.lastStoredOffset = 0
}
}
private func handleGestureOffsetChange(sideBarWidth: CGFloat, gestureOffset: CGFloat) {
if gestureOffset != 0 {
let potentialOffset = navigationState.lastStoredOffset + gestureOffset
if potentialOffset < sideBarWidth && potentialOffset > 0 {
navigationState.offset = potentialOffset
} else if potentialOffset < 0 {
navigationState.offset = 0
}
}
}
// MARK: Custom tab bar
var floatingToolBar: some View {
HStack {
Spacer()
Button {
self.currentTab = .home
} label: {
CustomTabItem(
symbolName: "house",
isActive: self.currentTab == .home)
}
Spacer()
Button {
self.currentTab = .settings
} label: {
CustomTabItem(
symbolName: "gear",
isActive: self.currentTab == .settings)
}
Spacer()
Button {
self.currentTab = .profile
} label: {
AsyncImage(url: URL(string: "https://cdn.pixabay.com/photo/2024/03/07/10/38/simba-8618301_640.jpg")!) { loadedImage in
loadedImage
.resizable()
.scaledToFit()
.clipShape(Circle())
.frame(maxWidth: .infinity)
.frame(height: 30)
.padding(2)
.background {
if self.currentTab == .profile {
Circle()
.stroke(.teal, lineWidth: 1)
}
}
} placeholder: {
ProgressView()
}
.frame(maxWidth: .infinity)
.frame(height: 30)
}
Spacer()
}
.frame(maxWidth: .infinity)
.padding(.top, 5)
.padding(.horizontal, 20)
.background(Color(uiColor: .systemGroupedBackground))
}
func CustomTabItem(symbolName: String, isActive: Bool) -> some View{
HStack {
Image(systemName: symbolName)
.resizable()
.foregroundColor(isActive ? .teal : .gray)
.opacity(isActive ? 1 : 0.6)
.frame(width: 22, height: 22)
}
.frame(maxWidth: .infinity)
.frame(height: 38)
}
}
// MARK: - Navigation State
class NavigationState: ObservableObject {
@Published var showMenu: Bool = false
@Published var offset: CGFloat = 0
@Published var lastStoredOffset: CGFloat = 0
func handleGestureEnd(value: DragGesture.Value, sideBarWidth: CGFloat) {
withAnimation(.spring(duration: 0.15)) {
if value.translation.width > 0 {
// Handle opening gesture
if value.translation.width > sideBarWidth / 2 {
openMenu(sideBarWidth: sideBarWidth)
} else if value.velocity.width > 800 {
openMenu(sideBarWidth: sideBarWidth)
} else if !showMenu {
closeMenu()
}
} else {
// Handle closing gesture
if -value.translation.width > sideBarWidth / 2 {
closeMenu()
} else {
guard showMenu else { return }
if -value.velocity.width > 800 {
closeMenu()
} else {
openMenu(sideBarWidth: sideBarWidth)
}
}
}
}
lastStoredOffset = offset
}
private func openMenu(sideBarWidth: CGFloat) {
offset = sideBarWidth
lastStoredOffset = sideBarWidth
showMenu = true
}
private func closeMenu() {
offset = 0
showMenu = false
}
}
// MARK: - Preview
#Preview {
ContentView()
.environmentObject(NavigationState())
}
このコードを使用すると、スワイプジェスチャーで開閉できるサイドバーメニューを実装できます。
ご覧いただきありがとうございます!
この記事で紹介したコードは以下のURLからご利用いただけます:
English Version: