SwiftUIで実装するカスタムタブバーとサイドバー（ツイッター風）

本記事では、SwiftUIアプリケーション内で、ユーザーアバターを含むカスタムタブバーと、スワイプ操作で開くサイドバーを実装する手順を紹介します。

ツイッター風 ?-> X風 ww

![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/38d72d61-fbfb-06de-822a-9c2428eca2fc.gif)

## カスタムタブバー

カスタムタブバーを利用することで、画像を載せたり、メニューを拡張したり（例えば、素早く別アカウントへ切り替えるといった機能を提供）することが容易になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/185e931a-202a-c71c-055c-390cb93f1a74.png)

まず、アプリで扱うタブをenumで定義します。
例：home, settings, profileといったタブを用意します。

```swift
enum CurrentTab {
    case home
    case settings
    case profile
}
```

選択中のタブを管理するために `@State` 変数を用意し、
`TabView` とバインドします。
TabView内には各ページ（Text("Home page")など）を.tag()で紐づけ、現在のタブを識別できるようにします。

```swift
@State private var currentTab: CurrentTab = .home
```

通常、TabView内の各ビューに.tabItemを指定すればシステム標準のタブバーが表示されますが、ここでは.tabItemを与えず、カスタムタブバーを上から重ねる方法を取ります。

```swift
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)で最下部にカスタムタブバーを重ねるようなレイアウトを構築します。

```swift
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:)を用いてアイコンを表示し、選択中の場合は色や不透明度を変える実装例を示します。

```swift
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から画像を非同期ロードします。

```swift
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を組み合わせ、カスタムタブバーを自由に拡張できます。
ユーザーの好みに合わせて、表示するタブを増減したり、テキストラベルを表示するなど柔軟な拡張も簡単です。

## カスタムサイドバー

続いて、左側からスワイプ操作でメニューを出し入れできるサイドバーを実装します。ツイッター風の「ドロワーメニュー」に近い体験を提供できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/28beb2c2-615c-45f5-84a8-f085ee76332a.png)

カスタムサイドバーを実装するために、HStackを使用してメインコンテンツの左側にメニューを配置します。
外側にGeometryReaderを使用して画面のサイズを読み取り、サイドバーの幅を計算します。

```swift
var body: some View {
    GeometryReader { geometry in
        let sideBarWidth = geometry.size.width - 100

    }
 }
 ```

 次に、サイドバーの幅を`sideBarWidth`として設定し、メインコンテンツビューの幅を画面の幅と同じに設定します。

 ```swift
 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軸のオフセットが更新され、サイドメニューの一部が表示されるようになります。

```diff_swift
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)`を定義することが重要です。これにより、画面全体の幅を埋めることができます。  
その後、ユーザーのジェスチャーを処理するための追加コードを追加します。

以下が完成したコードです：

```swift
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`関数では、複数の条件をチェックして、ユーザーが左から右にスワイプした場合にメニューを開くかどうかを判断します。

1. `if value.translation.width > sideBarWidth / 2`  
   この条件は、ユーザーがメニューの幅の半分以上スワイプした場合、メニューを開くことを意味します。

2. `else if value.velocity.width > 800`  
   この条件は、ユーザーがまだ必要な幅までスワイプしていないが、非常に速くスワイプしている場合に、メニューを開くようにします。

メニューを閉じるロジックも同様で、ユーザーがスワイプした距離と速度を確認して実行します。

これで、引き出しのように開閉できるサイドバーメニューが完成しました。

以下がサイドメニューとカスタムツールバーの完全なコードです：

```swift
//
//  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からご利用いただけます：  

https://github.com/mszpro/Custom-Side-Tab-Bar

English Version:

https://medium.com/@MszPro/custom-tab-bar-and-side-bar-twitter-like-for-swiftui-c7bca6afb9bb


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/4c80c1a0-a37e-549f-ee27-7cf48a6971ca.png)