上部左右から出現するメニューを作る
Published by @SoNiceInfo at 6/24/2020
SwiftUIで上部左右から出現するスライドメニューを作る方法を紹介します。
スワイプで上部左右から出てくるメニュー、ハンバーガーメニューを同時に実装します。
スワイプはgesture
、ハンバーガーメニューはButton(action:)
で実装します。
ここで紹介するコードを組み合わせれば、スライドメニューをNavigationView
の内外で表示することもできます。
スワイプによるメニューを実装するポイント
スワイプによりメニューを表示させるには以下のようなコードが重要となります。onChanged
, onEnded
で画面操作の動作中と終了を検知してそれぞれの動きを実装します。
.gesture(
// スワイプに関するイベントに関して検知します
// minimumDistanceを指定すると、その距離分のスワイプを検知した後に発火します
DragGesture(minimumDistance: 5)
// スワイプが検知されたときの動きを実装します
.onChanged{ value in
self.offset = v.translation.width
}
// スワイプが終了したときの動きを実装します
.onEnded { value in
if (value.translation.width > 0) {
self.offset = self.openOffset
} else {
self.offset = self.closeOffset
}
}
)
ハンバーガーメニューを実装するポイント
ハンバーガーメニューを実装するのはとても簡単です。Button(action:)
でメニューを開くための動きを実装します。
Button(action: {
self.offset = .zero
}){
Image(systemName: "list.bullet")
}
メニューを用意する
まずはメニューを用意します。以下のコードをコピペしてMenuView.swift
を作成してください。
作業簡略化のため「Twitterのホーム画面とスライドメニューを作る」のメニュー画面を使います。
スワイプに関する部分はContentView.swiftで実装します。
//
// MenuView.swift
//
import SwiftUI
struct MenuView: View {
var body: some View {
VStack(alignment: .leading) {
Image("animal_kuma")
.resizable()
.overlay(
Circle().stroke(Color.gray, lineWidth: 1))
.frame(width: 60, height: 60)
.clipShape(Circle())
Text("SwiftUIへの道")
.font(.largeTitle)
Text("@road2swiftui")
.font(.caption)
Divider()
ScrollView (.vertical, showsIndicators: true) {
HStack {
Image(systemName: "person")
Text("Profile")
}
HStack {
Image(systemName: "list.dash")
Text("Lists")
}
HStack {
Image(systemName: "text.bubble")
Text("Topics")
}
}
Divider()
Text("Settings and privacy")
}
.padding(.horizontal, 20)
}
}
struct MenuView_Previews: PreviewProvider {
static var previews: some View {
MenuView()
}
}
上部から出現
DragGesture
内のvalue.translation.height
が正の値だと上から下への移動となります。value.startLocation
を使用するとスワイプを始めた位置が取得できるので、画面上部からスワイプした場合のみメニューを出します。navigationBarItems
にハンバーガーメニューを設置します。
//
// ContentView.swift
//
import SwiftUI
struct ContentView: View {
// offset変数でメニューを表示・非表示するためのオフセットを保持します
@State private var offset = CGFloat.zero
@State private var closeOffset = CGFloat.zero
@State private var openOffset = CGFloat.zero
var body: some View {
// 画面サイズの取得にGeometoryReaderを利用します
GeometryReader { geometry in
NavigationView {
ZStack(alignment: .topLeading) {
// メインコンテンツ
VStack () {
Spacer()
Text("This is main contents")
.font(.largeTitle)
Spacer()
}
.background(Color.white)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
// スライドメニューがでてきたらメインコンテンツをグレイアウトします
Color.gray.opacity(
Double((self.closeOffset - self.offset)/self.closeOffset) - 0.4
)
// スライドメニュー
MenuView()
.background(Color.white)
.frame(width: geometry.size.width, height: geometry.size.height * 0.7)
// 最初に画面のオフセットの値をスライドメニュー分マイナスします。
.onAppear(perform: {
self.offset = (geometry.frame(in: .global).origin.y + geometry.size.height) * -1
self.closeOffset = self.offset
self.openOffset = .zero
})
.offset(y: self.offset)
// スライドのアニメーションを設定します
.animation(.default)
}
// ジェスチャーに関する実装をします
// スワイプのしきい値を設定してユーザの思わぬメニューの出現を防ぎます
.gesture(DragGesture(minimumDistance: 5)
.onChanged{ value in
// メインコンテンツの縦方向のスクロールを妨げないためにstartLocationを使用します
// オフセットの値(メニューの位置)をスワイプした距離に応じて狭めていきます
if (self.offset != self.openOffset && value.startLocation.y < 30) {
self.offset = self.closeOffset + value.translation.height
}
}
.onEnded { value in
// スワイプ開始位置よりも終了位置が下だったらメニューを開く
if (value.startLocation.y < value.location.y) {
if (value.startLocation.y < 30) {
self.offset = self.openOffset
}
} else {
self.offset = self.closeOffset
}
}
)
// 上部のNavigationBarに表示する項目を設定
.navigationBarTitle("This is bar", displayMode: .inline)
.navigationBarItems(trailing: Button(action: {
if (self.offset == self.openOffset) {
self.offset = self.closeOffset
} else {
self.offset = self.openOffset
}
}){
Image(systemName: "list.bullet")
})
.edgesIgnoringSafeArea(.bottom)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
左から出現
DragGesture
内のvalue.translation.width
が正の値だと左から右への移動となります。
//
// ContentView.swift
//
import SwiftUI
struct ContentView: View {
// offset変数でメニューを表示・非表示するためのオフセットを保持します
@State private var offset = CGFloat.zero
@State private var closeOffset = CGFloat.zero
@State private var openOffset = CGFloat.zero
var body: some View {
// 画面サイズの取得にGeometoryReaderを利用します
GeometryReader { geometry in
ZStack(alignment: .leading) {
// メインコンテンツ
VStack () {
HStack () {
Button(action: {
self.offset = self.openOffset
}){
Image(systemName: "list.bullet")
}
Spacer()
Text("This Is Bar")
Spacer()
}
.padding(.horizontal)
Divider()
Spacer()
Text("This is main contents")
.font(.largeTitle)
Spacer()
}
.background(Color.white)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
// スライドメニューがでてきたらメインコンテンツをグレイアウトします
Color.gray.opacity(
Double((self.closeOffset - self.offset)/self.closeOffset) - 0.4
)
// スライドメニュー
MenuView()
.background(Color.white)
.frame(width: geometry.size.width * 0.7)
// 最初に画面のオフセットの値をスライドメニュー分マイナスします。
.onAppear(perform: {
self.offset = geometry.size.width * -1
self.closeOffset = self.offset
self.openOffset = .zero
})
.offset(x: self.offset)
// スライドのアニメーションを設定します
.animation(.default)
}
// ジェスチャーに関する実装をします
// スワイプのしきい値を設定してユーザの思わぬメニューの出現を防ぎます
.gesture(DragGesture(minimumDistance: 5)
.onChanged{ value in
// オフセットの値(メニューの位置)をスワイプした距離に応じて狭めていきます
if (self.offset < self.openOffset) {
self.offset = self.closeOffset + value.translation.width
}
}
.onEnded { value in
// スワイプ終了位置が開始位置よりも右にあればメニューを開く
if (value.location.x > value.startLocation.x) {
self.offset = self.openOffset
} else {
self.offset = self.closeOffset
}
}
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
NavigationView
を使う場合はこちら。
//
// ContentView.swift
//
import SwiftUI
struct ContentView: View {
// offset変数でメニューを表示・非表示するためのオフセットを保持します
@State private var offset = CGFloat.zero
@State private var closeOffset = CGFloat.zero
@State private var openOffset = CGFloat.zero
var body: some View {
// 画面サイズの取得にGeometoryReaderを利用します
GeometryReader { geometry in
ZStack(alignment: .leading) {
// メインコンテンツ
NavigationView {
ZStack {
VStack () {
Spacer()
Text("This is main contents")
.font(.largeTitle)
Spacer()
}
// スライドメニューがでてきたらメインコンテンツをグレイアウトします
Color.gray.opacity(
Double((self.closeOffset - self.offset)/self.closeOffset) - 0.4
)
}
.navigationBarTitle("This is bar", displayMode: .inline)
.navigationBarItems(leading: Button(action: {
self.offset = self.openOffset
}){
Image(systemName: "list.bullet")
})
.edgesIgnoringSafeArea(.vertical)
}
// スライドメニュー
MenuView()
.background(Color.white)
.frame(width: geometry.size.width * 0.7)
.edgesIgnoringSafeArea(.bottom)
// 最初に画面のオフセットの値をスライドメニュー分マイナスします。
.onAppear(perform: {
self.offset = geometry.size.width * -1
self.closeOffset = self.offset
self.openOffset = .zero
})
.offset(x: self.offset)
// スライドのアニメーションを設定します
.animation(.default)
}
// ジェスチャーに関する実装をします
// スワイプのしきい値を設定してユーザの思わぬメニューの出現を防ぎます
.gesture(DragGesture(minimumDistance: 5)
.onChanged{ value in
// オフセットの値(メニューの位置)をスワイプした距離に応じて狭めていきます
if (self.offset < self.openOffset) {
self.offset = self.closeOffset + value.translation.width
}
}
.onEnded { value in
// スワイプの終了位置が開始位置よりも右だったらメニューを開く
if (value.location.x > value.startLocation.x) {
self.offset = self.openOffset
} else {
self.offset = self.closeOffset
}
}
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
右から出現
DragGesture
内のvalue.translation.width
が負の値だと右から左への移動となります。NavigationView
を使うパターンは割愛。
//
// ContentView.swift
//
import SwiftUI
struct ContentView: View {
// offset変数でメニューを表示・非表示するためのオフセットを保持します
@State private var offset = CGFloat.zero
@State private var closeOffset = CGFloat.zero
@State private var openOffset = CGFloat.zero
var body: some View {
// 画面サイズの取得にGeometoryReaderを利用します
GeometryReader { geometry in
ZStack(alignment: .leading) {
// メインコンテンツ
VStack () {
HStack () {
Spacer()
Text("This Is Bar")
Spacer()
Button(action: {
self.offset = self.openOffset
}){
Image(systemName: "list.bullet")
}
}
.padding(.horizontal)
Divider()
Spacer()
Text("This is main contents")
.font(.largeTitle)
Spacer()
}
.background(Color.white)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
// スライドメニューがでてきたらメインコンテンツをグレイアウトします
Color.gray.opacity(
Double((self.closeOffset - self.offset)/self.closeOffset) - 0.4
)
// スライドメニュー
MenuView()
.background(Color.white)
.frame(width: geometry.size.width * 0.7)
// 最初に画面のオフセットの値をスライドメニュー分マイナスします。
.onAppear(perform: {
self.offset = geometry.size.width
self.closeOffset = self.offset
self.openOffset = geometry.size.width - geometry.size.width * 0.7
})
.offset(x: self.offset)
// スライドのアニメーションを設定します
.animation(.default)
}
// ジェスチャーに関する実装をします
// スワイプのしきい値を設定してユーザの思わぬメニューの出現を防ぎます
.gesture(DragGesture(minimumDistance: 5)
.onChanged{ value in
// オフセットの値(メニューの位置)をスワイプした距離に応じて狭めていきます
if (self.offset > self.openOffset) {
self.offset = self.closeOffset + value.translation.width
}
}
.onEnded { value in
// value.translation.widthを使っても書けます
// 移動した距離がマイナス方向だったらメニューを開く
if (value.translation.width < 0) {
self.offset = self.openOffset
} else {
self.offset = self.closeOffset
}
}
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
ダウンロード
紹介するコードはGitHubで公開しています。
https://github.com/d1v1b/Various-Slide-Menus
その他
マップアプリやGoogleアプリで採用されている、画面下部にいるカード式スライドメニューの作り方はこちら。
下部から出現するカード風スライドメニューを作る