7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUIAdvent Calendar 2024

Day 9

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

Last updated at Posted at 2024-12-09

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

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

image.gif

カスタムタブバー

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

image.png

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

カスタムサイドバー

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

image.png

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

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

  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:

image.png

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?